initial
18
Justfile
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# LibreOffice JS desktop app
|
||||
|
||||
# Run the desktop app (Vite dev server + Tauri window, with hot reload).
|
||||
run:
|
||||
cd desktop && npm install && npx tauri dev
|
||||
|
||||
# Run only the frontend in a browser at http://localhost:1420 (no native file I/O).
|
||||
web:
|
||||
cd desktop && npm install && npm run dev
|
||||
|
||||
# Type-check and run the unit tests (desktop/src/**/qa).
|
||||
test:
|
||||
cd desktop && npm run typecheck && npm test
|
||||
cd desktop/src-tauri && cargo check
|
||||
|
||||
# Build the packaged app (desktop/src-tauri/target/release/bundle/).
|
||||
bundle:
|
||||
./desktop/scripts/build.sh
|
||||
90
README.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<img src="desktop/src-tauri/icons/128x128@2x.png" alt="LibreOffice JS logo" align="right" height="96" />
|
||||
|
||||
# LibreOffice JS
|
||||
|
||||
A [Tauri](https://tauri.app) + React word processor modelled on
|
||||
[LibreOffice](https://www.libreoffice.org) Writer. The frontend is organised
|
||||
like LibreOffice's `core` repository — the same module names (`sw`, `sfx2`,
|
||||
`framework`, `vcl`, `svx`, `editeng`, …) and, where it makes sense, the same
|
||||
file names — so upstream code can be used as the map for porting features.
|
||||
|
||||
> `core/` is a checkout of [LibreOffice/core](https://github.com/LibreOffice/core)
|
||||
> kept for reference; it is not built. `desktop/` is the app.
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
# one-time: Node >= 24 (see .nvmrc), Rust
|
||||
just run # Tauri window with hot reload
|
||||
just web # frontend only, in a browser at http://localhost:1420
|
||||
just test # typecheck + unit tests + cargo check
|
||||
just bundle # packaged app under desktop/src-tauri/target/release/bundle/
|
||||
```
|
||||
|
||||
No `just`? The recipes are one-liners in the [Justfile](Justfile).
|
||||
|
||||
## What works (skeleton)
|
||||
|
||||
- **Editing**: typing, Enter / Shift+Enter, Backspace/Delete (grapheme-aware),
|
||||
word/line deletes, IME composition, spell-check replacements, Tab.
|
||||
- **Undo/redo** with typing grouped into one step.
|
||||
- **Character formatting**: bold, italic, underline, strikethrough,
|
||||
super/subscript, font name/size, font colour, highlighting, clear direct
|
||||
formatting. With a collapsed cursor inside a word, formatting applies to
|
||||
the word (as in Writer); otherwise to the next typed text.
|
||||
- **Paragraphs**: alignment, indent, spacing, line spacing, page breaks
|
||||
(Ctrl+Enter), bulleted/numbered lists with levels.
|
||||
- **Paragraph styles**: Default, Body Text, Title, Subtitle, Heading 1–3,
|
||||
Quotations, Preformatted Text; "next style" on Enter.
|
||||
- **Pages**: A4 pages with pagination (whole paragraphs move between pages),
|
||||
zoom, ruler, formatting marks, printing.
|
||||
- **Files**: open/save `.odt` and `.fodt` (readable by LibreOffice), open/save
|
||||
`.txt`, export `.html`; "Save changes?" on close.
|
||||
- **UI**: menu bar, Standard and Formatting toolbars, context menu, sidebar
|
||||
(Properties, Styles, Navigator), status bar with word count and zoom,
|
||||
Find & Replace, Word Count, keyboard shortcuts from Writer.
|
||||
|
||||
Commands that exist in Writer but aren't implemented yet (tables, images,
|
||||
comments, Character/Paragraph dialogs, …) appear disabled in the menus.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
.
|
||||
├── core/ # upstream LibreOffice checkout (reference, not built)
|
||||
├── desktop/ # the app
|
||||
│ ├── src-tauri/ # Rust: file I/O commands, dialogs, window lifecycle
|
||||
│ ├── src/ # React frontend, laid out like core/:
|
||||
│ │ ├── desktop/ # app start-up (core/desktop)
|
||||
│ │ ├── sw/ # Writer (core/sw)
|
||||
│ │ │ ├── inc/ # model types: SwDoc, SwTextNode, SwPaM
|
||||
│ │ │ ├── source/core/ document model, styles, undo, layout, text frames
|
||||
│ │ │ ├── source/filter/ ODF (xml), HTML, plain text filters
|
||||
│ │ │ ├── source/uibase/ SwView, SwEditWin, SwWrtShell, shells, doc shell
|
||||
│ │ │ ├── source/ui/ dialogs
|
||||
│ │ │ ├── uiconfig/ # menubar/toolbar/popup XML (upstream format)
|
||||
│ │ │ └── qa/ # unit tests
|
||||
│ │ ├── sfx2/ # dispatcher, sidebar, doc medium, app slots
|
||||
│ │ ├── framework/ # menu bar / toolbar managers
|
||||
│ │ ├── vcl/ # widgets: menus, toolbox, combobox, dialogs
|
||||
│ │ ├── svx/ # toolbar controllers, sidebar panels, find dialog
|
||||
│ │ ├── svtools/ # ruler
|
||||
│ │ ├── editeng/ # character / paragraph attribute items
|
||||
│ │ ├── officecfg/ # command labels and accelerators
|
||||
│ │ ├── filter/ # filter registry
|
||||
│ │ ├── unotools/ # font substitution
|
||||
│ │ └── cui/ # About dialog
|
||||
│ └── scripts/build.sh # npm ci + tests + tauri build
|
||||
└── Justfile
|
||||
```
|
||||
|
||||
## Architecture in one paragraph
|
||||
|
||||
`SwDoc` is an immutable value (paragraphs made of attribute runs), so undo is
|
||||
a snapshot. `SwWrtShell` is the editing API. `SwEditWin` renders pages into a
|
||||
`contentEditable` host whose DOM is owned by React: every native edit is
|
||||
cancelled in `beforeinput` and replayed on the model, while the browser still
|
||||
handles caret movement, selection, IME and spell checking. UI commands are
|
||||
`.uno:` strings routed by `SfxDispatcher` through a shell stack (text shell →
|
||||
view → document → application), exactly like upstream; menus and toolbars are
|
||||
built from the same XML format as `core/sw/uiconfig`.
|
||||
17
desktop/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# LibreOffice JS desktop app
|
||||
|
||||
Tauri 2 shell (`src-tauri/`) around a React + Vite frontend (`src/`).
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npx tauri dev # desktop window, hot reload (runs `npm run dev` itself)
|
||||
npm run dev # browser only: http://localhost:1420 (downloads instead of saving)
|
||||
npm test # vitest: src/**/qa/**/*.test.ts
|
||||
npm run typecheck
|
||||
npx tauri build # bundle → src-tauri/target/release/bundle/
|
||||
```
|
||||
|
||||
The Rust side (`src-tauri/src/document.rs`) exposes `read_document`,
|
||||
`write_document` (raw byte IPC, atomic write) and `startup_document` (a file
|
||||
path passed on the command line). Everything else lives in the frontend; see
|
||||
the root README for the module layout.
|
||||
12
desktop/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LibreOffice JS Writer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2392
desktop/package-lock.json
generated
Normal file
33
desktop/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "libreoffice-js-desktop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -b",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.12.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.0",
|
||||
"fflate": "^0.8.3",
|
||||
"lucide-react": "^1.48.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.12.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.1",
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
}
|
||||
2
desktop/rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[toolchain]
|
||||
channel = "1.98.0"
|
||||
24
desktop/scripts/build.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build the packaged LibreOffice JS desktop app.
|
||||
#
|
||||
# Usage: ./desktop/scripts/build.sh
|
||||
#
|
||||
# The script:
|
||||
# 1. Installs the frontend dependencies (npm ci).
|
||||
# 2. Type-checks and runs the unit tests (src/**/qa).
|
||||
# 3. Builds the Tauri application (which runs `vite build` first).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DESKTOP="$ROOT/desktop"
|
||||
|
||||
echo "==> Installing frontend dependencies..."
|
||||
(cd "$DESKTOP" && npm ci)
|
||||
|
||||
echo "==> Running unit tests..."
|
||||
(cd "$DESKTOP" && npm test)
|
||||
|
||||
echo "==> Building LibreOffice JS Tauri app..."
|
||||
(cd "$DESKTOP" && npx tauri build)
|
||||
|
||||
echo "==> Done."
|
||||
11
desktop/scripts/icon.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<rect x="64" y="64" width="896" height="896" rx="200" fill="#1c5fa8"/>
|
||||
<path d="M300 200h300l160 160v464a40 40 0 0 1-40 40H300a40 40 0 0 1-40-40V240a40 40 0 0 1 40-40z" fill="#ffffff"/>
|
||||
<path d="M600 200v120a40 40 0 0 0 40 40h120z" fill="#b9d3f0"/>
|
||||
<g fill="#1c5fa8">
|
||||
<rect x="340" y="440" width="340" height="36" rx="18"/>
|
||||
<rect x="340" y="520" width="340" height="36" rx="18"/>
|
||||
<rect x="340" y="600" width="340" height="36" rx="18"/>
|
||||
<rect x="340" y="680" width="220" height="36" rx="18"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 622 B |
2
desktop/src-tauri/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
target/
|
||||
gen/
|
||||
4542
desktop/src-tauri/Cargo.lock
generated
Normal file
26
desktop/src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "libreoffice-js"
|
||||
version = "0.1.0"
|
||||
description = "LibreOffice JS — a Tauri + React office suite modelled on LibreOffice core"
|
||||
authors = ["LibreOffice JS"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "libreoffice_js_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "1"
|
||||
percent-encoding = "2"
|
||||
|
||||
[features]
|
||||
# This feature is used for production builds or when `devPath` points to the
|
||||
# filesystem and the built-in dev server is disabled.
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
3
desktop/src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
12
desktop/src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability for the LibreOffice JS main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-destroy",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
BIN
desktop/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
desktop/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
desktop/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 486 B |
BIN
desktop/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 874 B |
BIN
desktop/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
desktop/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
desktop/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
desktop/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
desktop/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 536 B |
BIN
desktop/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
desktop/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 709 B |
BIN
desktop/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 1,009 B |
BIN
desktop/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
desktop/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 780 B |
BIN
desktop/src-tauri/icons/icon.icns
Normal file
BIN
desktop/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
desktop/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 6 KiB |
71
desktop/src-tauri/src/document.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//! Document I/O commands — the native half of `sfx2/source/doc/docfile.ts`
|
||||
//! (SfxMedium). Bytes are moved as raw IPC payloads, not JSON arrays, so
|
||||
//! large `.odt` files don't balloon in transit.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use percent_encoding::percent_decode_str;
|
||||
use tauri::ipc::{InvokeBody, Request, Response};
|
||||
|
||||
/// Header carrying the (URI-encoded) target path for `write_document`, whose
|
||||
/// body is the raw file contents.
|
||||
const PATH_HEADER: &str = "x-document-path";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl serde::Serialize for Error {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_document(path: String) -> Result<Response, Error> {
|
||||
Ok(Response::new(std::fs::read(path)?))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_document(request: Request<'_>) -> Result<(), Error> {
|
||||
let InvokeBody::Raw(bytes) = request.body() else {
|
||||
return Err(Error::Invalid("expected a raw byte payload".into()));
|
||||
};
|
||||
let encoded = request
|
||||
.headers()
|
||||
.get(PATH_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| Error::Invalid(format!("missing {PATH_HEADER} header")))?;
|
||||
let path = PathBuf::from(
|
||||
percent_decode_str(encoded)
|
||||
.decode_utf8()
|
||||
.map_err(|e| Error::Invalid(e.to_string()))?
|
||||
.into_owned(),
|
||||
);
|
||||
|
||||
// Write next to the target and rename over it, so a failed save never
|
||||
// leaves a truncated document behind.
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| Error::Invalid("path has no file name".into()))?;
|
||||
let mut tmp_name = std::ffi::OsString::from(".~lock.");
|
||||
tmp_name.push(file_name);
|
||||
let tmp = path.with_file_name(tmp_name);
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(&tmp, &path).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The document passed on the command line (`libreoffice-js foo.odt`), if any.
|
||||
#[tauri::command]
|
||||
pub fn startup_document() -> Option<String> {
|
||||
std::env::args()
|
||||
.skip(1)
|
||||
.find(|arg| !arg.starts_with('-') && Path::new(arg).is_file())
|
||||
}
|
||||
20
desktop/src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! Native shell for LibreOffice JS.
|
||||
//!
|
||||
//! The document model, layout and UI all live in the React frontend
|
||||
//! (`desktop/src`, organised like LibreOffice's modules). The Rust side only
|
||||
//! provides what a webview can't: file-system access for the document
|
||||
//! filters (see `document.rs`), native dialogs, and window lifecycle.
|
||||
|
||||
mod document;
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
document::read_document,
|
||||
document::write_document,
|
||||
document::startup_document,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running LibreOffice JS");
|
||||
}
|
||||
5
desktop/src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
libreoffice_js_lib::run();
|
||||
}
|
||||
44
desktop/src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "LibreOffice JS",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.quineglobal.libreoffice",
|
||||
"build": {
|
||||
"devUrl": "http://localhost:1420",
|
||||
"frontendDist": "../dist",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "LibreOffice JS Writer",
|
||||
"width": 1280,
|
||||
"height": 860,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"signingIdentity": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
44
desktop/src/cui/source/dialogs/about.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/** Help > About — counterpart of cui/source/dialogs/about.cxx (AboutDialog). */
|
||||
import { Dialog } from "../../../vcl/source/window/dialog";
|
||||
|
||||
export const PRODUCT_NAME = "LibreOffice JS";
|
||||
export const PRODUCT_VERSION = "0.1.0";
|
||||
|
||||
export function AboutDialog({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Dialog
|
||||
title={`About ${PRODUCT_NAME}`}
|
||||
modal
|
||||
width={440}
|
||||
onClose={onClose}
|
||||
buttons={
|
||||
<button className="vcl-button" data-default="" autoFocus onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
|
||||
<svg width="56" height="56" viewBox="0 0 1024 1024" aria-hidden>
|
||||
<rect x="64" y="64" width="896" height="896" rx="200" fill="#1c5fa8" />
|
||||
<path d="M300 200h300l160 160v464a40 40 0 0 1-40 40H300a40 40 0 0 1-40-40V240a40 40 0 0 1 40-40z" fill="#fff" />
|
||||
<g fill="#1c5fa8">
|
||||
<rect x="340" y="440" width="340" height="36" rx="18" />
|
||||
<rect x="340" y="520" width="340" height="36" rx="18" />
|
||||
<rect x="340" y="600" width="340" height="36" rx="18" />
|
||||
</g>
|
||||
</svg>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{PRODUCT_NAME} Writer</div>
|
||||
<div style={{ color: "var(--lo-text-dim)", marginTop: 2 }}>Version {PRODUCT_VERSION}</div>
|
||||
<p style={{ margin: "12px 0 0" }}>
|
||||
A word processor built with Tauri and React, structured after LibreOffice's core modules
|
||||
(sw, sfx2, framework, vcl, svx, editeng, …).
|
||||
</p>
|
||||
<p style={{ margin: "8px 0 0", color: "var(--lo-text-dim)" }}>
|
||||
LibreOffice is a trademark of The Document Foundation. This project is not affiliated with it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
15
desktop/src/desktop/source/app/app.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Application start-up — counterpart of desktop/source/app/app.cxx
|
||||
* (Desktop::Main): registers UI components, then opens the Writer view.
|
||||
* Only Writer exists so far; Calc/Impress would be further modules beside sw.
|
||||
*/
|
||||
import { registerSvxToolBoxControllers } from "../../../svx/source/tbxctrls/tbcontrl";
|
||||
import { SwView } from "../../../sw/source/uibase/uiview/view";
|
||||
import "../../../vcl/source/app/vcl.css";
|
||||
import "../../../svx/source/svx.css";
|
||||
|
||||
registerSvxToolBoxControllers();
|
||||
|
||||
export function App() {
|
||||
return <SwView />;
|
||||
}
|
||||
36
desktop/src/editeng/source/items/paraitem.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Paragraph attributes — counterpart of editeng's paragraph items
|
||||
* (SvxAdjustItem, SvxLRSpaceItem, SvxULSpaceItem, SvxLineSpacingItem,
|
||||
* SvxFormatBreakItem) plus Writer's numbering rule reference (SwNumRuleItem),
|
||||
* collapsed into one set.
|
||||
*/
|
||||
export type SvxAdjust = "left" | "center" | "right" | "block";
|
||||
|
||||
export type SwListKind = "bullet" | "number";
|
||||
|
||||
export interface SvxParaAttrs {
|
||||
adjust?: SvxAdjust;
|
||||
/** Left indent in cm (excluding list indentation). */
|
||||
leftMargin?: number;
|
||||
/** Right indent in cm. */
|
||||
rightMargin?: number;
|
||||
/** Spacing above the paragraph in pt. */
|
||||
spaceBefore?: number;
|
||||
/** Spacing below the paragraph in pt. */
|
||||
spaceAfter?: number;
|
||||
/** Proportional line spacing in percent (100 = single). */
|
||||
lineHeight?: number;
|
||||
/** fo:break-before="page". */
|
||||
pageBreakBefore?: boolean;
|
||||
/** Paragraph is a list item of this kind. */
|
||||
list?: SwListKind;
|
||||
/** 1-based list level. */
|
||||
listLevel?: number;
|
||||
}
|
||||
|
||||
export const MAX_LIST_LEVEL = 10;
|
||||
|
||||
/** "Increase Indent" step for plain paragraphs, in cm (default tab stop). */
|
||||
export const INDENT_STEP_CM = 1.25;
|
||||
/** Indentation per list level, in cm. */
|
||||
export const LIST_INDENT_CM = 0.635;
|
||||
41
desktop/src/editeng/source/items/textitem.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Character attributes — the TypeScript counterpart of editeng's `Svx*Item`
|
||||
* family (SvxWeightItem, SvxPostureItem, SvxUnderlineItem, SvxFontItem, …).
|
||||
*
|
||||
* Upstream each attribute is a separate pool item keyed by a Which-ID; here a
|
||||
* plain object plays the role of an `SfxItemSet`. An explicit `false` is
|
||||
* meaningful: it overrides a value inherited from the paragraph style (e.g.
|
||||
* un-bolding part of a heading).
|
||||
*/
|
||||
export interface SvxCharAttrs {
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strikeout?: boolean;
|
||||
escapement?: "super" | "sub" | "none";
|
||||
/** Font family name. */
|
||||
fontName?: string;
|
||||
/** Font size in points. */
|
||||
fontSize?: number;
|
||||
/** Font colour as `#rrggbb`. */
|
||||
color?: string;
|
||||
/** Character highlighting (background) as `#rrggbb`. */
|
||||
highlight?: string;
|
||||
}
|
||||
|
||||
export type SvxCharAttrKey = keyof SvxCharAttrs;
|
||||
|
||||
/** Drops keys whose value is `undefined` so that set comparison is exact. */
|
||||
export function normalizeCharAttrs(attrs: SvxCharAttrs): SvxCharAttrs {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) out[k] = v;
|
||||
return out as SvxCharAttrs;
|
||||
}
|
||||
|
||||
export function charAttrsEqual(a: SvxCharAttrs, b: SvxCharAttrs): boolean {
|
||||
if (a === b) return true;
|
||||
const ka = Object.keys(a).filter((k) => a[k as SvxCharAttrKey] !== undefined);
|
||||
const kb = Object.keys(b).filter((k) => b[k as SvxCharAttrKey] !== undefined);
|
||||
if (ka.length !== kb.length) return false;
|
||||
return ka.every((k) => a[k as SvxCharAttrKey] === b[k as SvxCharAttrKey]);
|
||||
}
|
||||
56
desktop/src/filter/source/config/fragments/filters.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Filter registry — counterpart of filter/source/config/fragments/filters/*.xcu
|
||||
* (writer8.xcu, OpenDocument_Text_Flat_XML.xcu, HTML__StarWriter_.xcu,
|
||||
* Text.xcu), restricted to the filters implemented so far.
|
||||
*/
|
||||
export interface SfxFilter {
|
||||
/** Internal filter name, as in the .xcu fragment. */
|
||||
readonly name: string;
|
||||
readonly uiName: string;
|
||||
readonly extensions: readonly string[];
|
||||
readonly mimeType: string;
|
||||
readonly import: boolean;
|
||||
readonly export: boolean;
|
||||
}
|
||||
|
||||
export const WRITER_FILTERS: readonly SfxFilter[] = [
|
||||
{
|
||||
name: "writer8",
|
||||
uiName: "ODF Text Document",
|
||||
extensions: ["odt"],
|
||||
mimeType: "application/vnd.oasis.opendocument.text",
|
||||
import: true,
|
||||
export: true,
|
||||
},
|
||||
{
|
||||
name: "OpenDocument Text Flat XML",
|
||||
uiName: "Flat XML ODF Text Document",
|
||||
extensions: ["fodt"],
|
||||
mimeType: "application/vnd.oasis.opendocument.text-flat-xml",
|
||||
import: true,
|
||||
export: true,
|
||||
},
|
||||
{
|
||||
name: "HTML (StarWriter)",
|
||||
uiName: "HTML Document (Writer)",
|
||||
extensions: ["html", "htm"],
|
||||
mimeType: "text/html",
|
||||
import: false,
|
||||
export: true,
|
||||
},
|
||||
{
|
||||
name: "Text",
|
||||
uiName: "Text",
|
||||
extensions: ["txt"],
|
||||
mimeType: "text/plain",
|
||||
import: true,
|
||||
export: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_FILTER = WRITER_FILTERS[0];
|
||||
|
||||
export function filterForPath(path: string): SfxFilter | undefined {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
return WRITER_FILTERS.find((f) => f.extensions.includes(ext));
|
||||
}
|
||||
95
desktop/src/framework/source/uielement/menubarmanager.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* MenuBarManager — builds menus from uiconfig menubar/popupmenu XML and binds
|
||||
* each entry to its UNO command (framework/source/uielement/menubarmanager.cxx
|
||||
* + framework/source/fwe/xml/menudocumenthandler.cxx). Labels come from
|
||||
* officecfg, state from the dispatcher.
|
||||
*/
|
||||
import { commandLabel } from "../../../officecfg/registry/data/org/openoffice/Office/UI/GenericCommands";
|
||||
import { shortcutFor } from "../../../officecfg/registry/data/org/openoffice/Office/Accelerators";
|
||||
import { useDispatcher, type SfxDispatcher } from "../../../sfx2/source/control/dispatch";
|
||||
import { CommandImage } from "../../../vcl/source/image/ImplImageTree";
|
||||
import { MenuBar, PopupMenu, type VclMenuEntry } from "../../../vcl/source/window/menu";
|
||||
|
||||
export type MenuConfigEntry =
|
||||
| { kind: "separator" }
|
||||
| { kind: "item"; command: string }
|
||||
| { kind: "menu"; command: string; children: MenuConfigEntry[] };
|
||||
|
||||
function parseEntries(parent: Element): MenuConfigEntry[] {
|
||||
const out: MenuConfigEntry[] = [];
|
||||
for (const el of Array.from(parent.children)) {
|
||||
const command = el.getAttribute("menu:id") ?? "";
|
||||
if (el.localName === "menuseparator") out.push({ kind: "separator" });
|
||||
else if (el.localName === "menuitem") out.push({ kind: "item", command });
|
||||
else if (el.localName === "menu") {
|
||||
const popup = Array.from(el.children).find((c) => c.localName === "menupopup");
|
||||
out.push({ kind: "menu", command, children: popup ? parseEntries(popup) : [] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parses a menu:menubar or menu:menupopup document. */
|
||||
export function parseMenuXml(xml: string): MenuConfigEntry[] {
|
||||
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||
return parseEntries(doc.documentElement);
|
||||
}
|
||||
|
||||
function resolve(entries: MenuConfigEntry[], dispatcher: SfxDispatcher): VclMenuEntry[] {
|
||||
return entries.map((e): VclMenuEntry => {
|
||||
if (e.kind === "separator") return { kind: "separator" };
|
||||
if (e.kind === "menu") {
|
||||
const submenu = resolve(e.children, dispatcher);
|
||||
return {
|
||||
kind: "item",
|
||||
id: e.command,
|
||||
label: commandLabel(e.command),
|
||||
enabled: submenu.some((s) => s.kind === "item" && s.enabled),
|
||||
submenu,
|
||||
};
|
||||
}
|
||||
const state = dispatcher.queryState(e.command);
|
||||
return {
|
||||
kind: "item",
|
||||
id: e.command,
|
||||
label: commandLabel(e.command),
|
||||
shortcut: shortcutFor(e.command),
|
||||
icon: <CommandImage command={e.command} size={14} />,
|
||||
enabled: state.enabled,
|
||||
checked: state.checked,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function MenuBarManager({ config }: { config: MenuConfigEntry[] }) {
|
||||
const dispatcher = useDispatcher();
|
||||
const menus = config.flatMap((m) =>
|
||||
m.kind === "menu"
|
||||
? [{ id: m.command, label: commandLabel(m.command), entries: () => resolve(m.children, dispatcher) }]
|
||||
: [],
|
||||
);
|
||||
return <MenuBar menus={menus} onSelect={(cmd) => dispatcher.execute(cmd)} />;
|
||||
}
|
||||
|
||||
export function ContextMenuManager({
|
||||
config,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
}: {
|
||||
config: MenuConfigEntry[];
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dispatcher = useDispatcher();
|
||||
return (
|
||||
<PopupMenu
|
||||
entries={resolve(config, dispatcher)}
|
||||
x={x}
|
||||
y={y}
|
||||
onClose={onClose}
|
||||
onSelect={(cmd) => dispatcher.execute(cmd)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
81
desktop/src/framework/source/uielement/toolbarmanager.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* ToolBarManager — builds a ToolBox from uiconfig toolbar XML and binds its
|
||||
* items to UNO commands (framework/source/uielement/toolbarmanager.cxx).
|
||||
* Commands with a registered controller (font boxes, colour pickers — see
|
||||
* Controller.xcu upstream) render that controller instead of a button.
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { commandLabel } from "../../../officecfg/registry/data/org/openoffice/Office/UI/GenericCommands";
|
||||
import { shortcutFor } from "../../../officecfg/registry/data/org/openoffice/Office/Accelerators";
|
||||
import { useDispatcher } from "../../../sfx2/source/control/dispatch";
|
||||
import { CommandImage, hasCommandImage } from "../../../vcl/source/image/ImplImageTree";
|
||||
import { ToolBox, ToolBoxButton, ToolBoxSeparator } from "../../../vcl/source/window/toolbox";
|
||||
|
||||
export type ToolBarConfigEntry = { kind: "separator" } | { kind: "item"; command: string };
|
||||
|
||||
export function parseToolBarXml(xml: string): ToolBarConfigEntry[] {
|
||||
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||
return Array.from(doc.documentElement.children).flatMap((el): ToolBarConfigEntry[] => {
|
||||
if (el.localName === "toolbarseparator") return [{ kind: "separator" }];
|
||||
if (el.localName === "toolbaritem") {
|
||||
const command = el.getAttributeNS("http://www.w3.org/1999/xlink", "href") ?? el.getAttribute("xlink:href");
|
||||
return command ? [{ kind: "item", command }] : [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export interface ToolBoxControllerProps {
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** Toolbar controller factory (framework/source/uifactories/toolbarcontrollerfactory.cxx). */
|
||||
const controllers = new Map<string, ComponentType<ToolBoxControllerProps>>();
|
||||
|
||||
export function registerToolBoxController(
|
||||
command: string,
|
||||
controller: ComponentType<ToolBoxControllerProps>,
|
||||
): void {
|
||||
controllers.set(command, controller);
|
||||
}
|
||||
|
||||
export function tooltipFor(command: string): string {
|
||||
const shortcut = shortcutFor(command);
|
||||
return shortcut ? `${commandLabel(command, true)} (${shortcut})` : commandLabel(command, true);
|
||||
}
|
||||
|
||||
/** A plain command button (the generic ToolBox item). */
|
||||
export function CommandButton({ command }: ToolBoxControllerProps) {
|
||||
const dispatcher = useDispatcher();
|
||||
const state = dispatcher.queryState(command);
|
||||
return (
|
||||
<ToolBoxButton
|
||||
icon={<CommandImage command={command} />}
|
||||
text={hasCommandImage(command) ? undefined : commandLabel(command)}
|
||||
tooltip={tooltipFor(command)}
|
||||
checked={state.checked}
|
||||
disabled={!state.enabled}
|
||||
onClick={() => dispatcher.execute(command)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolBarItems({ entries }: { entries: ToolBarConfigEntry[] }) {
|
||||
return (
|
||||
<>
|
||||
{entries.map((e, i) => {
|
||||
if (e.kind === "separator") return <ToolBoxSeparator key={i} />;
|
||||
const Controller = controllers.get(e.command) ?? CommandButton;
|
||||
return <Controller key={e.command} command={e.command} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolBarManager({ config, label }: { config: ToolBarConfigEntry[]; label: string }) {
|
||||
return (
|
||||
<ToolBox label={label}>
|
||||
<ToolBarItems entries={config} />
|
||||
</ToolBox>
|
||||
);
|
||||
}
|
||||
9
desktop/src/main.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./desktop/source/app/app";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Keyboard shortcuts — counterpart of
|
||||
* officecfg/registry/data/org/openoffice/Office/Accelerators.xcu (the
|
||||
* Writer module list). "Mod" is Cmd on macOS and Ctrl elsewhere.
|
||||
*/
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
|
||||
export const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
export const ACCELERATORS: Record<string, string> = {
|
||||
"Mod+N": ".uno:AddDirect",
|
||||
"Mod+O": ".uno:Open",
|
||||
"Mod+S": ".uno:Save",
|
||||
"Mod+Shift+S": ".uno:SaveAs",
|
||||
"Mod+P": ".uno:Print",
|
||||
"Mod+Z": ".uno:Undo",
|
||||
[IS_MAC ? "Mod+Shift+Z" : "Mod+Y"]: ".uno:Redo",
|
||||
"Mod+A": ".uno:SelectAll",
|
||||
"Mod+Alt+Shift+V": ".uno:PasteUnformatted",
|
||||
"Mod+H": ".uno:SearchDialog",
|
||||
"Mod+F": ".uno:SearchDialog",
|
||||
"Mod+B": ".uno:Bold",
|
||||
"Mod+I": ".uno:Italic",
|
||||
"Mod+U": ".uno:Underline",
|
||||
"Mod+Shift+P": ".uno:SuperScript",
|
||||
"Mod+Shift+B": ".uno:SubScript",
|
||||
"Mod+L": ".uno:LeftPara",
|
||||
"Mod+E": ".uno:CenterPara",
|
||||
"Mod+R": ".uno:RightPara",
|
||||
"Mod+J": ".uno:JustifyPara",
|
||||
"F12": ".uno:DefaultNumbering",
|
||||
"Shift+F12": ".uno:DefaultBullet",
|
||||
"Mod+M": ".uno:ResetAttributes",
|
||||
"Mod+0": ".uno:TextBodyParaStyle",
|
||||
"Mod+1": ".uno:Heading1ParaStyle",
|
||||
"Mod+2": ".uno:Heading2ParaStyle",
|
||||
"Mod+3": ".uno:Heading3ParaStyle",
|
||||
"Mod+Enter": ".uno:InsertPagebreak",
|
||||
"Mod+F10": ".uno:ControlCodes",
|
||||
"Mod+F5": ".uno:Sidebar",
|
||||
"F5": ".uno:Navigator",
|
||||
"F11": ".uno:DesignerDialog",
|
||||
"F7": ".uno:SpellingAndGrammarDialog",
|
||||
"Mod+Shift+R": ".uno:Ruler",
|
||||
"Mod+=": ".uno:ZoomIn",
|
||||
"Mod+-": ".uno:ZoomOut",
|
||||
};
|
||||
|
||||
const CODE_KEYS: Record<string, string> = { Equal: "=", Minus: "-", Enter: "Enter", NumpadEnter: "Enter" };
|
||||
|
||||
/** Normalises a key event to the accelerator notation above. */
|
||||
export function keyEventToAccel(e: KeyboardEvent | ReactKeyboardEvent): string {
|
||||
let key: string;
|
||||
if (/^Key[A-Z]$/.test(e.code)) key = e.code.slice(3);
|
||||
else if (/^Digit\d$/.test(e.code)) key = e.code.slice(5);
|
||||
else key = CODE_KEYS[e.code] ?? e.key;
|
||||
const mods = [
|
||||
(IS_MAC ? e.metaKey : e.ctrlKey) && "Mod",
|
||||
e.altKey && "Alt",
|
||||
e.shiftKey && "Shift",
|
||||
].filter(Boolean);
|
||||
return [...mods, key].join("+");
|
||||
}
|
||||
|
||||
const byCommand = new Map<string, string>();
|
||||
for (const [accel, cmd] of Object.entries(ACCELERATORS)) if (!byCommand.has(cmd)) byCommand.set(cmd, accel);
|
||||
|
||||
/** Display form of a command's shortcut ("Ctrl+B" / "⌘B"). */
|
||||
export function shortcutFor(cmd: string): string | undefined {
|
||||
const accel = byCommand.get(cmd);
|
||||
if (!accel) return undefined;
|
||||
if (!IS_MAC) return accel.replace("Mod", "Ctrl");
|
||||
return accel
|
||||
.replace("Mod+", "⌘")
|
||||
.replace("Shift+", "⇧")
|
||||
.replace("Alt+", "⌥")
|
||||
.replace("Enter", "↩");
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* UNO command labels — counterpart of
|
||||
* officecfg/registry/data/org/openoffice/Office/UI/GenericCommands.xcu and
|
||||
* WriterCommands.xcu. "~" marks the mnemonic, as upstream.
|
||||
*/
|
||||
export interface UnoCommandInfo {
|
||||
/** Menu / general label. */
|
||||
label: string;
|
||||
/** Tooltip, when different from the label. */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export const COMMANDS: Record<string, UnoCommandInfo> = {
|
||||
// Top-level menus
|
||||
".uno:PickList": { label: "~File" },
|
||||
".uno:EditMenu": { label: "~Edit" },
|
||||
".uno:ViewMenu": { label: "~View" },
|
||||
".uno:InsertMenu": { label: "~Insert" },
|
||||
".uno:FormatMenu": { label: "F~ormat" },
|
||||
".uno:FormatStylesMenu": { label: "St~yles" },
|
||||
".uno:TableMenu": { label: "T~able" },
|
||||
".uno:ToolsMenu": { label: "~Tools" },
|
||||
".uno:WindowList": { label: "~Window" },
|
||||
".uno:HelpMenu": { label: "~Help" },
|
||||
// Submenus
|
||||
".uno:FormatTextMenu": { label: "Te~xt" },
|
||||
".uno:AlignTextMenu": { label: "~Align Text" },
|
||||
".uno:FormatBulletsMenu": { label: "~Lists" },
|
||||
".uno:InsertField": { label: "Field" },
|
||||
".uno:ZoomMenu": { label: "~Zoom" },
|
||||
".uno:ExportAsMenu": { label: "Expor~t As" },
|
||||
|
||||
// File
|
||||
".uno:AddDirect": { label: "~New Document", tooltip: "Create New Document" },
|
||||
".uno:Open": { label: "~Open...", tooltip: "Open File" },
|
||||
".uno:RecentFileList": { label: "Recent Doc~uments" },
|
||||
".uno:CloseDoc": { label: "~Close" },
|
||||
".uno:Save": { label: "~Save", tooltip: "Save" },
|
||||
".uno:SaveAs": { label: "Save ~As...", tooltip: "Save As" },
|
||||
".uno:ExportTo": { label: "E~xport..." },
|
||||
".uno:ExportDirectToPDF": { label: "Export Directly as PDF", tooltip: "Export Directly as PDF" },
|
||||
".uno:ExportToHTML": { label: "Export as ~HTML..." },
|
||||
".uno:ExportToText": { label: "Export as ~Plain Text..." },
|
||||
".uno:Print": { label: "~Print...", tooltip: "Print" },
|
||||
".uno:PrinterSetup": { label: "P~rinter Settings..." },
|
||||
".uno:SetDocumentProperties": { label: "Propert~ies..." },
|
||||
".uno:Quit": { label: "E~xit LibreOffice" },
|
||||
|
||||
// Edit
|
||||
".uno:Undo": { label: "~Undo" },
|
||||
".uno:Redo": { label: "~Redo" },
|
||||
".uno:Repeat": { label: "Repeat" },
|
||||
".uno:Cut": { label: "Cu~t" },
|
||||
".uno:Copy": { label: "~Copy" },
|
||||
".uno:Paste": { label: "~Paste" },
|
||||
".uno:PasteUnformatted": { label: "Paste Unformatted Text" },
|
||||
".uno:SelectAll": { label: "Select ~All" },
|
||||
".uno:SearchDialog": { label: "Find & Rep~lace...", tooltip: "Find & Replace" },
|
||||
".uno:TrackChanges": { label: "~Record Changes" },
|
||||
".uno:FormatPaintbrush": { label: "Clone", tooltip: "Clone Formatting" },
|
||||
|
||||
// View
|
||||
".uno:Ruler": { label: "~Rulers" },
|
||||
".uno:StatusBarVisible": { label: "Status ~Bar" },
|
||||
".uno:ControlCodes": { label: "Formatting Mar~ks", tooltip: "Toggle Formatting Marks" },
|
||||
".uno:Sidebar": { label: "Side~bar" },
|
||||
".uno:Navigator": { label: "Na~vigator" },
|
||||
".uno:FullScreen": { label: "F~ull Screen" },
|
||||
".uno:ZoomIn": { label: "Zoom ~In" },
|
||||
".uno:ZoomOut": { label: "Zoom ~Out" },
|
||||
".uno:Zoom100Percent": { label: "100%" },
|
||||
|
||||
// Insert
|
||||
".uno:InsertPagebreak": { label: "Page ~Break", tooltip: "Insert Page Break" },
|
||||
".uno:InsertTable": { label: "~Table...", tooltip: "Insert Table" },
|
||||
".uno:InsertGraphic": { label: "~Image...", tooltip: "Insert Image" },
|
||||
".uno:InsertObjectChart": { label: "Chart...", tooltip: "Insert Chart" },
|
||||
".uno:HyperlinkDialog": { label: "Hyperlin~k...", tooltip: "Insert Hyperlink" },
|
||||
".uno:CharmapControl": { label: "S~pecial Character...", tooltip: "Insert Special Character" },
|
||||
".uno:InsertAnnotation": { label: "Co~mment", tooltip: "Insert Comment" },
|
||||
".uno:InsertDateField": { label: "~Date" },
|
||||
".uno:InsertTimeField": { label: "~Time" },
|
||||
".uno:InsertFootnote": { label: "~Footnote" },
|
||||
|
||||
// Format
|
||||
".uno:Bold": { label: "Bold" },
|
||||
".uno:Italic": { label: "Italic" },
|
||||
".uno:Underline": { label: "Underline" },
|
||||
".uno:Strikeout": { label: "Strikethrough" },
|
||||
".uno:SuperScript": { label: "Superscript" },
|
||||
".uno:SubScript": { label: "Subscript" },
|
||||
".uno:LeftPara": { label: "Left", tooltip: "Align Left" },
|
||||
".uno:CenterPara": { label: "Centered", tooltip: "Align Center" },
|
||||
".uno:RightPara": { label: "Right", tooltip: "Align Right" },
|
||||
".uno:JustifyPara": { label: "Justified", tooltip: "Justified" },
|
||||
".uno:DefaultBullet": { label: "Unordered List", tooltip: "Toggle Unordered List" },
|
||||
".uno:DefaultNumbering": { label: "Ordered List", tooltip: "Toggle Ordered List" },
|
||||
".uno:IncrementIndent": { label: "Increase Indent" },
|
||||
".uno:DecrementIndent": { label: "Decrease Indent" },
|
||||
".uno:ResetAttributes": { label: "Clear ~Direct Formatting", tooltip: "Clear Direct Formatting" },
|
||||
".uno:FontColor": { label: "Font Color" },
|
||||
".uno:CharBackColor": { label: "Character Highlighting Color" },
|
||||
".uno:FontDialog": { label: "C~haracter..." },
|
||||
".uno:ParagraphDialog": { label: "P~aragraph..." },
|
||||
".uno:BulletsAndNumberingDialog": { label: "Bu~llets and Numbering..." },
|
||||
".uno:PageDialog": { label: "Page St~yle..." },
|
||||
".uno:FormatColumns": { label: "Colu~mns..." },
|
||||
".uno:StyleApply": { label: "Paragraph Style", tooltip: "Set Paragraph Style" },
|
||||
".uno:CharFontName": { label: "Font Name" },
|
||||
".uno:FontHeight": { label: "Font Size" },
|
||||
|
||||
// Styles
|
||||
".uno:DefaultParaStyle": { label: "Default Paragraph Style" },
|
||||
".uno:TitleParaStyle": { label: "Title" },
|
||||
".uno:SubtitleParaStyle": { label: "Subtitle" },
|
||||
".uno:Heading1ParaStyle": { label: "Heading 1" },
|
||||
".uno:Heading2ParaStyle": { label: "Heading 2" },
|
||||
".uno:Heading3ParaStyle": { label: "Heading 3" },
|
||||
".uno:TextBodyParaStyle": { label: "Body Text" },
|
||||
".uno:QuoteParaStyle": { label: "Quotations" },
|
||||
".uno:PreformattedParaStyle": { label: "Preformatted Text" },
|
||||
".uno:DesignerDialog": { label: "Manage Styles", tooltip: "Styles" },
|
||||
|
||||
// Table
|
||||
".uno:InsertRowsAfter": { label: "Rows ~Below" },
|
||||
".uno:InsertColumnsAfter": { label: "Columns ~After" },
|
||||
".uno:DeleteTable": { label: "~Delete Table" },
|
||||
|
||||
// Tools
|
||||
".uno:SpellingAndGrammarDialog": { label: "~Spelling...", tooltip: "Check Spelling" },
|
||||
".uno:WordCountDialog": { label: "~Word Count..." },
|
||||
".uno:AutoCorrectDlg": { label: "~AutoCorrect Options..." },
|
||||
".uno:OptionsTreeDialog": { label: "~Options..." },
|
||||
|
||||
// Window / Help
|
||||
".uno:NewWindow": { label: "~New Window" },
|
||||
".uno:CloseWin": { label: "~Close Window" },
|
||||
".uno:HelpIndex": { label: "LibreOffice ~Help" },
|
||||
".uno:About": { label: "A~bout LibreOffice JS" },
|
||||
};
|
||||
|
||||
export function commandLabel(cmd: string, forTooltip = false): string {
|
||||
const info = COMMANDS[cmd];
|
||||
if (!info) return cmd.replace(/^\.uno:/, "");
|
||||
return ((forTooltip && info.tooltip) || info.label).replace("~", "");
|
||||
}
|
||||
29
desktop/src/sfx2/source/appl/appserv.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Application-level slots — counterpart of sfx2/source/appl/appserv.cxx and
|
||||
* appopen.cxx (SID_NEWDOCDIRECT, SID_OPENDOC, SID_ABOUT, SID_QUITAPP).
|
||||
*/
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { SfxShell } from "../control/dispatch";
|
||||
|
||||
export interface SfxApplicationContext {
|
||||
newDocument(): Promise<void>;
|
||||
openDocument(): Promise<void>;
|
||||
showAbout(): void;
|
||||
}
|
||||
|
||||
export function createAppShell(ctx: SfxApplicationContext): SfxShell {
|
||||
// Closing the window goes through onCloseRequested, which asks to save.
|
||||
const quit = () => (isTauri() ? getCurrentWindow().close() : undefined);
|
||||
return {
|
||||
name: "SfxApplication",
|
||||
slots: {
|
||||
".uno:AddDirect": { exec: () => ctx.newDocument() },
|
||||
".uno:Open": { exec: () => ctx.openDocument() },
|
||||
".uno:About": { exec: () => ctx.showAbout() },
|
||||
...(isTauri()
|
||||
? { ".uno:Quit": { exec: quit }, ".uno:CloseWin": { exec: quit } }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
71
desktop/src/sfx2/source/control/dispatch.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* SfxDispatcher — routes UNO commands (".uno:Bold") to the shells on the
|
||||
* shell stack (sfx2/source/control/dispatch.cxx). Each SfxShell exposes a
|
||||
* slot map (the SFX_IMPL_INTERFACE / .sdi slot tables upstream); the first
|
||||
* shell from the top that has the slot handles both Execute and GetState.
|
||||
* Commands no shell implements report as disabled, which is how the menus
|
||||
* show not-yet-implemented features.
|
||||
*/
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type SfxArgs = Record<string, unknown>;
|
||||
|
||||
export interface SfxItemState {
|
||||
enabled: boolean;
|
||||
/** Toggle state for checkable commands. */
|
||||
checked?: boolean;
|
||||
/** Current value for controls (style name, font, size, …). */
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface SfxSlot {
|
||||
exec: (args?: SfxArgs) => unknown;
|
||||
state?: () => Partial<SfxItemState>;
|
||||
}
|
||||
|
||||
export interface SfxShell {
|
||||
readonly name: string;
|
||||
readonly slots: Readonly<Record<string, SfxSlot>>;
|
||||
}
|
||||
|
||||
export class SfxDispatcher {
|
||||
/** `shells[0]` is the top of the stack. */
|
||||
constructor(
|
||||
private readonly shells: readonly SfxShell[],
|
||||
private readonly onError: (command: string, error: unknown) => void = (c, e) =>
|
||||
console.error(`[sfx2] ${c} failed`, e),
|
||||
) {}
|
||||
|
||||
private slot(command: string): SfxSlot | undefined {
|
||||
for (const shell of this.shells) {
|
||||
const slot = shell.slots[command];
|
||||
if (slot) return slot;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
queryState(command: string): SfxItemState {
|
||||
const slot = this.slot(command);
|
||||
if (!slot) return { enabled: false };
|
||||
return { enabled: true, ...slot.state?.() };
|
||||
}
|
||||
|
||||
/** Executes a command; returns false if it is unknown or disabled. */
|
||||
execute(command: string, args?: SfxArgs): boolean {
|
||||
const slot = this.slot(command);
|
||||
if (!slot || !this.queryState(command).enabled) return false;
|
||||
try {
|
||||
const result = slot.exec(args);
|
||||
if (result instanceof Promise) result.catch((e) => this.onError(command, e));
|
||||
} catch (e) {
|
||||
this.onError(command, e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const SfxDispatcherContext = createContext<SfxDispatcher>(new SfxDispatcher([]));
|
||||
|
||||
export function useDispatcher(): SfxDispatcher {
|
||||
return useContext(SfxDispatcherContext);
|
||||
}
|
||||
41
desktop/src/sfx2/source/dialog/StyleList.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Styles deck — counterpart of sfx2/source/dialog/StyleList.cxx (the style
|
||||
* list shared by the Styles sidebar deck and the old Stylist). Paragraph
|
||||
* styles only for now; applying dispatches .uno:StyleApply like upstream.
|
||||
*/
|
||||
import type { CSSProperties } from "react";
|
||||
import { useDispatcher } from "../control/dispatch";
|
||||
import { useViewFrame } from "../view/viewfrm";
|
||||
|
||||
export interface SfxStyleListEntry {
|
||||
uiName: string;
|
||||
preview?: CSSProperties;
|
||||
}
|
||||
|
||||
export function StyleList() {
|
||||
const dispatcher = useDispatcher();
|
||||
const frame = useViewFrame();
|
||||
const state = dispatcher.queryState(".uno:StyleApply");
|
||||
const value = state.value as { current: string; styles: SfxStyleListEntry[] } | undefined;
|
||||
return (
|
||||
<div className="sfx-stylelist">
|
||||
<div className="sfx-stylelist-family">Paragraph Styles</div>
|
||||
<ul role="listbox" aria-label="Paragraph Styles">
|
||||
{(value?.styles ?? []).map((s) => (
|
||||
<li
|
||||
key={s.uiName}
|
||||
role="option"
|
||||
aria-selected={s.uiName === value?.current}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
dispatcher.execute(".uno:StyleApply", { Style: s.uiName, FamilyName: "ParagraphStyles" });
|
||||
frame.grabFocus();
|
||||
}}
|
||||
>
|
||||
<span style={s.preview}>{s.uiName}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
desktop/src/sfx2/source/doc/docfile.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* SfxMedium — where document bytes come from and go to
|
||||
* (sfx2/source/doc/docfile.cxx). Inside Tauri files go through the native
|
||||
* `read_document` / `write_document` commands (src-tauri/src/document.rs)
|
||||
* and the native file dialogs; in a plain browser (`npm run dev`) it falls
|
||||
* back to an <input type=file> and a download.
|
||||
*/
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import type { SfxFilter } from "../../../filter/source/config/fragments/filters";
|
||||
|
||||
export interface SfxMediumFile {
|
||||
/** Native path, or just the file name in the browser. */
|
||||
path: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export function baseName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() ?? path;
|
||||
}
|
||||
|
||||
function dialogFilters(filters: readonly SfxFilter[]) {
|
||||
const all = filters.flatMap((f) => f.extensions);
|
||||
return [
|
||||
{ name: "All Formats", extensions: all },
|
||||
...filters.map((f) => ({ name: f.uiName, extensions: [...f.extensions] })),
|
||||
];
|
||||
}
|
||||
|
||||
function browserPick(filters: readonly SfxFilter[]): Promise<SfxMediumFile | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = filters.flatMap((f) => f.extensions.map((e) => `.${e}`)).join(",");
|
||||
input.addEventListener("change", async () => {
|
||||
const file = input.files?.[0];
|
||||
resolve(file ? { path: file.name, bytes: new Uint8Array(await file.arrayBuffer()) } : null);
|
||||
});
|
||||
input.addEventListener("cancel", () => resolve(null));
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/** File > Open: asks for a file and reads it. */
|
||||
export async function pickAndRead(filters: readonly SfxFilter[]): Promise<SfxMediumFile | null> {
|
||||
if (!isTauri()) return browserPick(filters);
|
||||
const path = await open({ multiple: false, directory: false, filters: dialogFilters(filters) });
|
||||
return path ? { path, bytes: await readFile(path) } : null;
|
||||
}
|
||||
|
||||
export async function readFile(path: string): Promise<Uint8Array> {
|
||||
return new Uint8Array(await invoke<ArrayBuffer>("read_document", { path }));
|
||||
}
|
||||
|
||||
/** File > Save As: asks where to save. */
|
||||
export async function pickSavePath(
|
||||
defaultName: string,
|
||||
filters: readonly SfxFilter[],
|
||||
): Promise<string | null> {
|
||||
if (!isTauri()) return defaultName;
|
||||
return save({ defaultPath: defaultName, filters: filters.map((f) => ({ name: f.uiName, extensions: [...f.extensions] })) });
|
||||
}
|
||||
|
||||
export async function writeFile(path: string, bytes: Uint8Array, mimeType: string): Promise<void> {
|
||||
if (isTauri()) {
|
||||
await invoke("write_document", bytes, {
|
||||
headers: { "x-document-path": encodeURIComponent(path) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: mimeType }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = baseName(path);
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
/** The document passed on the command line, if any. */
|
||||
export async function startupDocument(): Promise<string | null> {
|
||||
return isTauri() ? invoke<string | null>("startup_document") : null;
|
||||
}
|
||||
58
desktop/src/sfx2/source/sidebar/SidebarController.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Sidebar — counterpart of sfx2/source/sidebar/SidebarController.cxx with its
|
||||
* TabBar and Deck. Decks are supplied by the application module (Writer
|
||||
* registers Properties, Styles and Navigator, as in Sidebar.xcu).
|
||||
*/
|
||||
import { X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { CommandImage } from "../../../vcl/source/image/ImplImageTree";
|
||||
|
||||
export interface SfxSidebarDeck {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Command whose image is used for the tab. */
|
||||
icon: string;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
decks,
|
||||
current,
|
||||
onDeck,
|
||||
onClose,
|
||||
}: {
|
||||
decks: SfxSidebarDeck[];
|
||||
current: string;
|
||||
onDeck: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const deck = decks.find((d) => d.id === current) ?? decks[0];
|
||||
return (
|
||||
<aside className="sfx-sidebar">
|
||||
<div className="sfx-deck">
|
||||
<div className="sfx-deck-title">
|
||||
<span>{deck.title}</span>
|
||||
<button aria-label="Close Sidebar Deck" title="Close Sidebar Deck" onClick={onClose}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="sfx-deck-content">{deck.content}</div>
|
||||
</div>
|
||||
<nav className="sfx-tabbar" aria-label="Sidebar decks">
|
||||
{decks.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
title={d.title}
|
||||
aria-label={d.title}
|
||||
aria-pressed={d.id === deck.id}
|
||||
className="vcl-toolbox-button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onDeck(d.id)}
|
||||
>
|
||||
<CommandImage command={d.icon} />
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
16
desktop/src/sfx2/source/view/viewfrm.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* SfxViewFrame — the frame hosting a document view (sfx2/source/view/viewfrm.cxx).
|
||||
* Controls outside the document (toolbar boxes, sidebar fields) use it to
|
||||
* hand the keyboard focus back to the document window when they are done.
|
||||
*/
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export interface SfxViewFrame {
|
||||
grabFocus(): void;
|
||||
}
|
||||
|
||||
export const SfxViewFrameContext = createContext<SfxViewFrame>({ grabFocus: () => {} });
|
||||
|
||||
export function useViewFrame(): SfxViewFrame {
|
||||
return useContext(SfxViewFrameContext);
|
||||
}
|
||||
51
desktop/src/svtools/source/control/ruler.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Horizontal ruler — counterpart of svtools/source/control/ruler.cxx (drawn
|
||||
* by SvxRuler for Writer). Shows the page width, the margins and the current
|
||||
* paragraph's indents. Display-only for now (no dragging of indents/tabs).
|
||||
*/
|
||||
import { CM_TO_PX } from "../../../sw/inc/doc";
|
||||
|
||||
export interface RulerProps {
|
||||
pageWidth: number;
|
||||
marginLeft: number;
|
||||
marginRight: number;
|
||||
indentLeft: number;
|
||||
indentRight: number;
|
||||
/** Scale factor (1 = 100%). */
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export function Ruler({ pageWidth, marginLeft, marginRight, indentLeft, indentRight, scale }: RulerProps) {
|
||||
const px = (cm: number) => cm * CM_TO_PX * scale;
|
||||
const width = px(pageWidth);
|
||||
const textWidth = pageWidth - marginLeft - marginRight;
|
||||
const ticks = [];
|
||||
// Ticks every 0.25cm, numbered every cm, counted from the left margin.
|
||||
for (let q = -Math.floor(marginLeft * 4); q <= Math.floor((pageWidth - marginLeft) * 4); q++) {
|
||||
const x = px(marginLeft + q / 4);
|
||||
const major = q % 4 === 0;
|
||||
const half = q % 2 === 0;
|
||||
if (major && q !== 0) {
|
||||
ticks.push(
|
||||
<text key={q} x={x} y={14} textAnchor="middle" className="svt-ruler-num">
|
||||
{Math.abs(q / 4)}
|
||||
</text>,
|
||||
);
|
||||
} else if (!major) {
|
||||
ticks.push(<line key={q} x1={x} x2={x} y1={half ? 7 : 9} y2={half ? 15 : 13} className="svt-ruler-tick" />);
|
||||
}
|
||||
}
|
||||
const il = px(marginLeft + indentLeft);
|
||||
const ir = px(marginLeft + textWidth - indentRight);
|
||||
return (
|
||||
<div className="svt-ruler" aria-hidden>
|
||||
<svg width={width} height={22} style={{ display: "block", margin: "0 auto" }}>
|
||||
<rect x={0} y={3} width={width} height={16} className="svt-ruler-margin" />
|
||||
<rect x={px(marginLeft)} y={3} width={px(textWidth)} height={16} className="svt-ruler-text" />
|
||||
{ticks}
|
||||
<path d={`M${il - 5} 21 L${il + 5} 21 L${il} 15 Z`} className="svt-ruler-indent" />
|
||||
<path d={`M${ir - 5} 21 L${ir + 5} 21 L${ir} 15 Z`} className="svt-ruler-indent" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
desktop/src/svx/source/dialog/srchdlg.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Find & Replace — counterpart of svx/source/dialog/srchdlg.cxx
|
||||
* (SvxSearchDialog). Modeless: the dialog only collects the search item and
|
||||
* hands it to the view (`onSearch`), which does the searching, like
|
||||
* SID_SEARCH_ITEM being executed by SwView upstream.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Dialog } from "../../../vcl/source/window/dialog";
|
||||
|
||||
export type SvxSearchCmd = "find" | "findPrevious" | "replace" | "replaceAll";
|
||||
|
||||
export interface SvxSearchItem {
|
||||
command: SvxSearchCmd;
|
||||
searchString: string;
|
||||
replaceString: string;
|
||||
matchCase: boolean;
|
||||
wholeWords: boolean;
|
||||
}
|
||||
|
||||
export function SvxSearchDialog({
|
||||
initialSearch,
|
||||
onSearch,
|
||||
onClose,
|
||||
}: {
|
||||
initialSearch: string;
|
||||
/** Executes the search; returns a status message for the dialog. */
|
||||
onSearch: (item: SvxSearchItem) => string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [searchString, setSearch] = useState(initialSearch);
|
||||
const [replaceString, setReplace] = useState("");
|
||||
const [matchCase, setMatchCase] = useState(false);
|
||||
const [wholeWords, setWholeWords] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
const run = (command: SvxSearchCmd) =>
|
||||
setStatus(onSearch({ command, searchString, replaceString, matchCase, wholeWords }));
|
||||
|
||||
const disabled = !searchString;
|
||||
return (
|
||||
<Dialog
|
||||
title="Find and Replace"
|
||||
width={440}
|
||||
onClose={onClose}
|
||||
buttons={
|
||||
<>
|
||||
<button className="vcl-button" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="svx-search-grid">
|
||||
<label htmlFor="svx-search">Find:</label>
|
||||
<input
|
||||
id="svx-search"
|
||||
className="vcl-field"
|
||||
autoFocus
|
||||
value={searchString}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !disabled) run(e.shiftKey ? "findPrevious" : "find");
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="svx-replace">Replace:</label>
|
||||
<input
|
||||
id="svx-replace"
|
||||
className="vcl-field"
|
||||
value={replaceString}
|
||||
onChange={(e) => setReplace(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !disabled) run("replace");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="svx-search-options">
|
||||
<label className="vcl-checkbox">
|
||||
<input type="checkbox" checked={matchCase} onChange={(e) => setMatchCase(e.target.checked)} />
|
||||
Match case
|
||||
</label>
|
||||
<label className="vcl-checkbox">
|
||||
<input type="checkbox" checked={wholeWords} onChange={(e) => setWholeWords(e.target.checked)} />
|
||||
Whole words only
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 14 }}>
|
||||
<button className="vcl-button" disabled={disabled} onClick={() => run("findPrevious")}>
|
||||
Find Previous
|
||||
</button>
|
||||
<button className="vcl-button" data-default="" disabled={disabled} onClick={() => run("find")}>
|
||||
Find Next
|
||||
</button>
|
||||
<button className="vcl-button" disabled={disabled} onClick={() => run("replace")}>
|
||||
Replace
|
||||
</button>
|
||||
<button className="vcl-button" disabled={disabled} onClick={() => run("replaceAll")}>
|
||||
Replace All
|
||||
</button>
|
||||
</div>
|
||||
<div className="svx-search-status" role="status">
|
||||
{status}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
139
desktop/src/svx/source/sidebar/paragraph/ParaPropertyPanel.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Paragraph panel of the Properties deck — counterpart of
|
||||
* svx/source/sidebar/paragraph/ParaPropertyPanel.cxx (alignment, lists,
|
||||
* indent, spacing and line spacing).
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { ToolBarItems, type ToolBarConfigEntry } from "../../../../framework/source/uielement/toolbarmanager";
|
||||
import { useDispatcher } from "../../../../sfx2/source/control/dispatch";
|
||||
import { useViewFrame } from "../../../../sfx2/source/view/viewfrm";
|
||||
|
||||
const item = (command: string): ToolBarConfigEntry => ({ kind: "item", command });
|
||||
const sep: ToolBarConfigEntry = { kind: "separator" };
|
||||
|
||||
/** A metric field that commits on Enter / blur (vcl MetricField). */
|
||||
function MetricField({
|
||||
label,
|
||||
unit,
|
||||
value,
|
||||
step,
|
||||
onCommit,
|
||||
}: {
|
||||
label: string;
|
||||
unit: string;
|
||||
value: number;
|
||||
step: number;
|
||||
onCommit: (v: number) => void;
|
||||
}) {
|
||||
const frame = useViewFrame();
|
||||
const [text, setText] = useState(String(value));
|
||||
useEffect(() => setText(String(+value.toFixed(2))), [value]);
|
||||
const commit = () => {
|
||||
const v = parseFloat(text);
|
||||
if (Number.isFinite(v) && v >= 0 && v !== value) onCommit(v);
|
||||
else setText(String(+value.toFixed(2)));
|
||||
};
|
||||
return (
|
||||
<div className="svx-panel-row">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
className="vcl-field"
|
||||
type="number"
|
||||
min={0}
|
||||
step={step}
|
||||
style={{ width: 80 }}
|
||||
aria-label={label}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
commit();
|
||||
frame.grabFocus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: "var(--lo-text-dim)", marginLeft: 4 }}>{unit}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LINE_SPACINGS = [
|
||||
[".uno:SpacePara1", "Single"],
|
||||
[".uno:SpacePara115", "1.15 Lines"],
|
||||
[".uno:SpacePara15", "1.5 Lines"],
|
||||
[".uno:SpacePara2", "Double"],
|
||||
] as const;
|
||||
|
||||
export function ParaPropertyPanel() {
|
||||
const dispatcher = useDispatcher();
|
||||
const frame = useViewFrame();
|
||||
const spacing = (dispatcher.queryState(".uno:ULSpacing").value ?? { upper: 0, lower: 0 }) as {
|
||||
upper: number;
|
||||
lower: number;
|
||||
};
|
||||
const indent = (dispatcher.queryState(".uno:LeftRightParaMargin").value ?? 0) as number;
|
||||
const lineSpacing = LINE_SPACINGS.find(([cmd]) => dispatcher.queryState(cmd).checked)?.[0] ?? "";
|
||||
|
||||
return (
|
||||
<section className="svx-panel" aria-label="Paragraph">
|
||||
<div className="svx-panel-title">Paragraph</div>
|
||||
<div className="svx-panel-row">
|
||||
<ToolBarItems
|
||||
entries={[
|
||||
item(".uno:LeftPara"),
|
||||
item(".uno:CenterPara"),
|
||||
item(".uno:RightPara"),
|
||||
item(".uno:JustifyPara"),
|
||||
sep,
|
||||
item(".uno:DefaultBullet"),
|
||||
item(".uno:DefaultNumbering"),
|
||||
sep,
|
||||
item(".uno:IncrementIndent"),
|
||||
item(".uno:DecrementIndent"),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<MetricField
|
||||
label="Above"
|
||||
unit="pt"
|
||||
step={1}
|
||||
value={spacing.upper}
|
||||
onCommit={(v) => dispatcher.execute(".uno:ULSpacing", { Upper: v, Lower: spacing.lower })}
|
||||
/>
|
||||
<MetricField
|
||||
label="Below"
|
||||
unit="pt"
|
||||
step={1}
|
||||
value={spacing.lower}
|
||||
onCommit={(v) => dispatcher.execute(".uno:ULSpacing", { Upper: spacing.upper, Lower: v })}
|
||||
/>
|
||||
<MetricField
|
||||
label="Indent"
|
||||
unit="cm"
|
||||
step={0.25}
|
||||
value={indent}
|
||||
onCommit={(v) => dispatcher.execute(".uno:LeftRightParaMargin", { Left: v })}
|
||||
/>
|
||||
<div className="svx-panel-row">
|
||||
<label htmlFor="svx-linespacing">Line spacing</label>
|
||||
<select
|
||||
id="svx-linespacing"
|
||||
className="vcl-field"
|
||||
value={lineSpacing}
|
||||
onChange={(e) => {
|
||||
dispatcher.execute(e.target.value);
|
||||
frame.grabFocus();
|
||||
}}
|
||||
>
|
||||
{lineSpacing === "" && <option value="">Proportional</option>}
|
||||
{LINE_SPACINGS.map(([cmd, label]) => (
|
||||
<option key={cmd} value={cmd}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
38
desktop/src/svx/source/sidebar/text/TextPropertyPanel.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Character panel of the Properties deck — counterpart of
|
||||
* svx/source/sidebar/text/TextPropertyPanel.cxx. Like upstream it contains
|
||||
* the same toolbar controllers as the Formatting toolbar.
|
||||
*/
|
||||
import { ToolBarItems, type ToolBarConfigEntry } from "../../../../framework/source/uielement/toolbarmanager";
|
||||
|
||||
const item = (command: string): ToolBarConfigEntry => ({ kind: "item", command });
|
||||
const sep: ToolBarConfigEntry = { kind: "separator" };
|
||||
|
||||
export function TextPropertyPanel() {
|
||||
return (
|
||||
<section className="svx-panel" aria-label="Character">
|
||||
<div className="svx-panel-title">Character</div>
|
||||
<div className="svx-panel-row">
|
||||
<ToolBarItems entries={[item(".uno:CharFontName"), item(".uno:FontHeight")]} />
|
||||
</div>
|
||||
<div className="svx-panel-row">
|
||||
<ToolBarItems
|
||||
entries={[
|
||||
item(".uno:Bold"),
|
||||
item(".uno:Italic"),
|
||||
item(".uno:Underline"),
|
||||
item(".uno:Strikeout"),
|
||||
sep,
|
||||
item(".uno:SuperScript"),
|
||||
item(".uno:SubScript"),
|
||||
sep,
|
||||
item(".uno:ResetAttributes"),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="svx-panel-row">
|
||||
<ToolBarItems entries={[item(".uno:FontColor"), item(".uno:CharBackColor")]} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
48
desktop/src/svx/source/stbctrls/zoomsliderctrl.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Zoom slider in the status bar — counterpart of
|
||||
* svx/source/stbctrls/zoomsliderctrl.cxx (SvxZoomSliderControl).
|
||||
*/
|
||||
export const MIN_ZOOM = 20;
|
||||
export const MAX_ZOOM = 600;
|
||||
|
||||
/** Zoom steps used by Zoom In / Zoom Out (basegfx zoomtools). */
|
||||
const STEPS = [20, 25, 33, 50, 67, 75, 90, 100, 110, 125, 150, 175, 200, 250, 300, 400, 500, 600];
|
||||
|
||||
export function zoomIn(z: number): number {
|
||||
return STEPS.find((s) => s > z) ?? MAX_ZOOM;
|
||||
}
|
||||
|
||||
export function zoomOut(z: number): number {
|
||||
return [...STEPS].reverse().find((s) => s < z) ?? MIN_ZOOM;
|
||||
}
|
||||
|
||||
export function SvxZoomSliderControl({ zoom, onZoom }: { zoom: number; onZoom: (z: number) => void }) {
|
||||
return (
|
||||
<div className="svx-zoomslider">
|
||||
<button aria-label="Zoom Out" title="Zoom Out" onClick={() => onZoom(zoomOut(zoom))}>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Zoom"
|
||||
min={MIN_ZOOM}
|
||||
max={300}
|
||||
value={Math.min(zoom, 300)}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
onZoom(Math.abs(v - 100) <= 4 ? 100 : v);
|
||||
}}
|
||||
/>
|
||||
<button aria-label="Zoom In" title="Zoom In" onClick={() => onZoom(zoomIn(zoom))}>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
style={{ width: "auto", padding: "0 4px" }}
|
||||
title="Reset zoom to 100%"
|
||||
onClick={() => onZoom(100)}
|
||||
>
|
||||
{Math.round(zoom)}%
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
desktop/src/svx/source/svx.css
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/* svx controls: palette, zoom slider, sidebar panels, search dialog. */
|
||||
|
||||
.svx-palette {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 18px);
|
||||
gap: 3px;
|
||||
padding: 6px 4px 4px;
|
||||
}
|
||||
|
||||
.svx-palette-cell {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.svx-palette-cell:hover {
|
||||
outline: 2px solid var(--lo-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.svx-zoomslider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.svx-zoomslider button {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: none;
|
||||
color: var(--lo-text-dim);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.svx-zoomslider button:hover {
|
||||
background: var(--lo-hover);
|
||||
}
|
||||
|
||||
.svx-zoomslider input[type="range"] {
|
||||
width: 110px;
|
||||
accent-color: var(--lo-accent);
|
||||
}
|
||||
|
||||
.svx-panel {
|
||||
border-bottom: 1px solid var(--lo-border-soft);
|
||||
padding: 8px 10px 12px;
|
||||
}
|
||||
|
||||
.svx-panel-title {
|
||||
font-weight: 600;
|
||||
margin: 2px 0 8px;
|
||||
}
|
||||
|
||||
.svx-panel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.svx-panel-row > label {
|
||||
color: var(--lo-text-dim);
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.svx-panel .vcl-combobox {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.svx-panel .vcl-toolbox-button {
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.svx-search-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
}
|
||||
|
||||
.svx-search-options {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.svx-search-status {
|
||||
min-height: 18px;
|
||||
margin-top: 10px;
|
||||
color: var(--lo-text-dim);
|
||||
}
|
||||
184
desktop/src/svx/source/tbxctrls/tbcontrl.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* Toolbar controllers for the formatting toolbar — counterpart of
|
||||
* svx/source/tbxctrls/tbcontrl.cxx (SvxStyleToolBoxControl,
|
||||
* SvxFontNameToolBoxControl, SvxFontSizeBox) and
|
||||
* svx/source/tbxctrls/tbxcolor.cxx / PaletteManager (colour split buttons).
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
registerToolBoxController,
|
||||
tooltipFor,
|
||||
type ToolBoxControllerProps,
|
||||
} from "../../../framework/source/uielement/toolbarmanager";
|
||||
import { useDispatcher } from "../../../sfx2/source/control/dispatch";
|
||||
import { useViewFrame } from "../../../sfx2/source/view/viewfrm";
|
||||
import { FONT_LIST, FONT_SIZES, cssFontFamily } from "../../../unotools/source/misc/fontdefs";
|
||||
import { ComboBox } from "../../../vcl/source/control/combobox";
|
||||
import { CommandImage } from "../../../vcl/source/image/ImplImageTree";
|
||||
import { ToolBoxSplitButton } from "../../../vcl/source/window/toolbox";
|
||||
|
||||
export interface StyleBoxEntry {
|
||||
uiName: string;
|
||||
preview?: { fontFamily?: string; fontSize?: string; fontWeight?: number };
|
||||
}
|
||||
|
||||
/** Paragraph style box (.uno:StyleApply). State value: { current, styles }. */
|
||||
export function SvxStyleBox({ command }: ToolBoxControllerProps) {
|
||||
const dispatcher = useDispatcher();
|
||||
const frame = useViewFrame();
|
||||
const state = dispatcher.queryState(command);
|
||||
const value = state.value as { current: string; styles: StyleBoxEntry[] } | undefined;
|
||||
const styles = value?.styles ?? [];
|
||||
return (
|
||||
<ComboBox
|
||||
label={tooltipFor(command)}
|
||||
width={180}
|
||||
disabled={!state.enabled}
|
||||
value={value?.current ?? ""}
|
||||
options={styles.map((s) => s.uiName)}
|
||||
optionStyle={(o) => styles.find((s) => s.uiName === o)?.preview}
|
||||
onCommit={(v) => dispatcher.execute(command, { Style: v, FamilyName: "ParagraphStyles" })}
|
||||
onDone={() => frame.grabFocus()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Font name box (.uno:CharFontName). */
|
||||
export function SvxFontNameBox({ command }: ToolBoxControllerProps) {
|
||||
const dispatcher = useDispatcher();
|
||||
const frame = useViewFrame();
|
||||
const state = dispatcher.queryState(command);
|
||||
return (
|
||||
<ComboBox
|
||||
label={tooltipFor(command)}
|
||||
width={180}
|
||||
disabled={!state.enabled}
|
||||
value={String(state.value ?? "")}
|
||||
options={FONT_LIST}
|
||||
optionStyle={(o) => ({ fontFamily: cssFontFamily(o), fontSize: 14 })}
|
||||
onCommit={(v) => dispatcher.execute(command, { CharFontName: v })}
|
||||
onDone={() => frame.grabFocus()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Font size box (.uno:FontHeight). */
|
||||
export function SvxFontSizeBox({ command }: ToolBoxControllerProps) {
|
||||
const dispatcher = useDispatcher();
|
||||
const frame = useViewFrame();
|
||||
const state = dispatcher.queryState(command);
|
||||
const size = state.value as number | undefined;
|
||||
return (
|
||||
<ComboBox
|
||||
label={tooltipFor(command)}
|
||||
width={70}
|
||||
disabled={!state.enabled}
|
||||
value={size ? `${size} pt` : ""}
|
||||
options={FONT_SIZES.map((s) => `${s} pt`)}
|
||||
onCommit={(v) => {
|
||||
const n = parseFloat(v);
|
||||
if (n >= 1 && n <= 999.9) dispatcher.execute(command, { FontHeight: Math.round(n * 10) / 10 });
|
||||
}}
|
||||
onDone={() => frame.grabFocus()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** LibreOffice's "standard" palette (extras/source/palettes/standard.soc), abridged. */
|
||||
export const STANDARD_PALETTE: readonly (readonly [string, string])[] = [
|
||||
["Black", "#000000"], ["Dark Gray 3", "#333333"], ["Dark Gray 2", "#666666"], ["Gray", "#808080"],
|
||||
["Light Gray 1", "#999999"], ["Light Gray 2", "#b2b2b2"], ["Light Gray 3", "#cccccc"], ["Light Gray 4", "#dddddd"],
|
||||
["Light Gray 5", "#eeeeee"], ["White", "#ffffff"], ["Dark Red 2", "#8d281e"], ["Dark Blue 2", "#1e3b5c"],
|
||||
["Yellow", "#ffff00"], ["Gold", "#ffbf00"], ["Orange", "#ff8000"], ["Brick", "#ff4000"],
|
||||
["Red", "#ff0000"], ["Magenta", "#bf0041"], ["Purple", "#800080"], ["Indigo", "#55308d"],
|
||||
["Blue", "#2a6099"], ["Teal", "#158466"], ["Green", "#00a933"], ["Lime", "#81d41a"],
|
||||
["Light Yellow 2", "#ffffa6"], ["Light Gold 2", "#ffe994"], ["Light Orange 2", "#ffb66c"], ["Light Brick 2", "#ffaa95"],
|
||||
["Light Red 2", "#ffa6a6"], ["Light Magenta 2", "#ec9ba4"], ["Light Purple 2", "#bf819e"], ["Light Indigo 2", "#b7b3ca"],
|
||||
["Light Blue 2", "#b4c7dc"], ["Light Teal 2", "#b3cac7"], ["Light Green 2", "#afd095"], ["Light Lime 2", "#e8f2a1"],
|
||||
["Dark Yellow 2", "#996600"], ["Dark Gold 2", "#b47804"], ["Dark Orange 2", "#b85c00"], ["Dark Brick 2", "#be480a"],
|
||||
["Dark Red 1", "#c9211e"], ["Dark Magenta 2", "#861141"], ["Dark Purple 2", "#650953"], ["Dark Indigo 2", "#43215f"],
|
||||
["Dark Blue 1", "#355269"], ["Dark Teal 2", "#1e6a39"], ["Dark Green 2", "#127622"], ["Dark Lime 2", "#468a1a"],
|
||||
];
|
||||
|
||||
const LAST_COLOR: Record<string, string> = {
|
||||
".uno:FontColor": "#c9211e",
|
||||
".uno:CharBackColor": "#ffff00",
|
||||
};
|
||||
|
||||
/** Colour split button with palette popup (SvxColorToolBoxControl). */
|
||||
export function SvxColorToolBoxControl({ command }: ToolBoxControllerProps) {
|
||||
const dispatcher = useDispatcher();
|
||||
const state = dispatcher.queryState(command);
|
||||
const [last, setLast] = useState(LAST_COLOR[command] ?? "#000000");
|
||||
const [popup, setPopup] = useState<{ x: number; y: number } | null>(null);
|
||||
const noneLabel = command === ".uno:FontColor" ? "Automatic" : "No Fill";
|
||||
|
||||
useEffect(() => {
|
||||
if (!popup) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!(e.target as Element).closest(".svx-color-window")) setPopup(null);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
return () => document.removeEventListener("mousedown", onDown, true);
|
||||
}, [popup]);
|
||||
|
||||
const apply = (color: string | null) => {
|
||||
setPopup(null);
|
||||
if (color) {
|
||||
setLast(color);
|
||||
LAST_COLOR[command] = color;
|
||||
}
|
||||
dispatcher.execute(command, { Color: color });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolBoxSplitButton
|
||||
icon={
|
||||
<span style={{ display: "inline-flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<CommandImage command={command} size={16} />
|
||||
<span className="vcl-color-swatch" style={{ background: last }} />
|
||||
</span>
|
||||
}
|
||||
tooltip={tooltipFor(command)}
|
||||
disabled={!state.enabled}
|
||||
onClick={() => apply(last)}
|
||||
onDropDown={(e) => {
|
||||
const r = e.currentTarget.parentElement!.getBoundingClientRect();
|
||||
setPopup(popup ? null : { x: r.left, y: r.bottom + 2 });
|
||||
}}
|
||||
/>
|
||||
{popup && (
|
||||
<div
|
||||
className="vcl-menu svx-color-window"
|
||||
style={{ left: popup.x, top: popup.y, minWidth: 0 }}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<button className="vcl-menu-item" onClick={() => apply(null)}>
|
||||
{noneLabel}
|
||||
</button>
|
||||
<div className="svx-palette">
|
||||
{STANDARD_PALETTE.map(([name, color]) => (
|
||||
<button
|
||||
key={color}
|
||||
title={name}
|
||||
aria-label={name}
|
||||
className="svx-palette-cell"
|
||||
style={{ background: color }}
|
||||
onClick={() => apply(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function registerSvxToolBoxControllers(): void {
|
||||
registerToolBoxController(".uno:StyleApply", SvxStyleBox);
|
||||
registerToolBoxController(".uno:CharFontName", SvxFontNameBox);
|
||||
registerToolBoxController(".uno:FontHeight", SvxFontSizeBox);
|
||||
registerToolBoxController(".uno:FontColor", SvxColorToolBoxControl);
|
||||
registerToolBoxController(".uno:CharBackColor", SvxColorToolBoxControl);
|
||||
}
|
||||
36
desktop/src/sw/inc/doc.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* SwDoc — the document. Upstream (sw/inc/doc.hxx) it owns the node array
|
||||
* (SwNodes), format pools, page descriptors and the undo manager; here it is
|
||||
* an immutable value so that undo is a snapshot and React can diff it.
|
||||
*/
|
||||
import type { SwTextNode } from "./ndtxt";
|
||||
|
||||
/** Page style (SwPageDesc). All lengths in cm. */
|
||||
export interface SwPageDesc {
|
||||
readonly name: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly marginTop: number;
|
||||
readonly marginBottom: number;
|
||||
readonly marginLeft: number;
|
||||
readonly marginRight: number;
|
||||
}
|
||||
|
||||
export interface SwDoc {
|
||||
/** Body text nodes. Always contains at least one paragraph. */
|
||||
readonly nodes: readonly SwTextNode[];
|
||||
readonly pageDesc: SwPageDesc;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_DESC: SwPageDesc = {
|
||||
name: "Default Page Style",
|
||||
width: 21.0,
|
||||
height: 29.7,
|
||||
marginTop: 2.0,
|
||||
marginBottom: 2.0,
|
||||
marginLeft: 2.0,
|
||||
marginRight: 2.0,
|
||||
};
|
||||
|
||||
/** 1cm in CSS px (96dpi). */
|
||||
export const CM_TO_PX = 96 / 2.54;
|
||||
51
desktop/src/sw/inc/ndtxt.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* SwTextNode — a paragraph. Upstream (sw/inc/ndtxt.hxx) text is an OUString
|
||||
* with attribute "hints" (SwpHints) layered on top; here the text is stored
|
||||
* as attribute runs, which keeps immutable updates simple. The run list is
|
||||
* always normalised (see thints.ts): adjacent runs never share attributes,
|
||||
* and only an empty paragraph has a single empty run (to remember the
|
||||
* attributes the next typed character will get).
|
||||
*
|
||||
* Inside a paragraph "\n" is a manual line break and "\t" a tab.
|
||||
*/
|
||||
import type { SvxCharAttrs } from "../../editeng/source/items/textitem";
|
||||
import type { SvxParaAttrs } from "../../editeng/source/items/paraitem";
|
||||
|
||||
export interface SwTextRun {
|
||||
readonly text: string;
|
||||
readonly attrs: SvxCharAttrs;
|
||||
}
|
||||
|
||||
export interface SwTextNode {
|
||||
/** Stable identity across edits; used as the React key of its frame. */
|
||||
readonly id: string;
|
||||
/** Programmatic name of the paragraph style (SwTextFormatColl). */
|
||||
readonly style: string;
|
||||
/** Direct paragraph formatting. */
|
||||
readonly attrs: SvxParaAttrs;
|
||||
readonly runs: readonly SwTextRun[];
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
export function newNodeId(): string {
|
||||
return `n${nextId++}`;
|
||||
}
|
||||
|
||||
export function makeTextNode(
|
||||
text = "",
|
||||
style = "Standard",
|
||||
attrs: SvxParaAttrs = {},
|
||||
charAttrs: SvxCharAttrs = {},
|
||||
): SwTextNode {
|
||||
return { id: newNodeId(), style, attrs, runs: [{ text, attrs: charAttrs }] };
|
||||
}
|
||||
|
||||
export function getNodeText(node: SwTextNode): string {
|
||||
return node.runs.map((r) => r.text).join("");
|
||||
}
|
||||
|
||||
export function nodeLength(node: SwTextNode): number {
|
||||
let n = 0;
|
||||
for (const r of node.runs) n += r.text.length;
|
||||
return n;
|
||||
}
|
||||
67
desktop/src/sw/inc/pam.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* SwPosition / SwPaM — positions and selections in the document
|
||||
* (sw/inc/pam.hxx). A position is a node index plus a character offset; a
|
||||
* PaM ("point and mark") is a selection whose `point` is the moving end.
|
||||
*/
|
||||
import type { SwDoc } from "./doc";
|
||||
import { nodeLength } from "./ndtxt";
|
||||
|
||||
export interface SwPosition {
|
||||
readonly node: number;
|
||||
readonly offset: number;
|
||||
}
|
||||
|
||||
export interface SwPaM {
|
||||
readonly mark: SwPosition;
|
||||
readonly point: SwPosition;
|
||||
}
|
||||
|
||||
export function pos(node: number, offset: number): SwPosition {
|
||||
return { node, offset };
|
||||
}
|
||||
|
||||
export function collapsed(p: SwPosition): SwPaM {
|
||||
return { mark: p, point: p };
|
||||
}
|
||||
|
||||
export function comparePos(a: SwPosition, b: SwPosition): number {
|
||||
return a.node !== b.node ? a.node - b.node : a.offset - b.offset;
|
||||
}
|
||||
|
||||
export function posEquals(a: SwPosition, b: SwPosition): boolean {
|
||||
return a.node === b.node && a.offset === b.offset;
|
||||
}
|
||||
|
||||
export function pamEquals(a: SwPaM, b: SwPaM): boolean {
|
||||
return posEquals(a.mark, b.mark) && posEquals(a.point, b.point);
|
||||
}
|
||||
|
||||
/** True if the selection spans text (SwPaM::HasMark && mark != point). */
|
||||
export function hasSelection(pam: SwPaM): boolean {
|
||||
return !posEquals(pam.mark, pam.point);
|
||||
}
|
||||
|
||||
export function pamStart(pam: SwPaM): SwPosition {
|
||||
return comparePos(pam.mark, pam.point) <= 0 ? pam.mark : pam.point;
|
||||
}
|
||||
|
||||
export function pamEnd(pam: SwPaM): SwPosition {
|
||||
return comparePos(pam.mark, pam.point) <= 0 ? pam.point : pam.mark;
|
||||
}
|
||||
|
||||
export function clampPos(doc: SwDoc, p: SwPosition): SwPosition {
|
||||
const node = Math.max(0, Math.min(p.node, doc.nodes.length - 1));
|
||||
const offset = Math.max(0, Math.min(p.offset, nodeLength(doc.nodes[node])));
|
||||
return node === p.node && offset === p.offset ? p : { node, offset };
|
||||
}
|
||||
|
||||
export function clampPaM(doc: SwDoc, pam: SwPaM): SwPaM {
|
||||
const mark = clampPos(doc, pam.mark);
|
||||
const point = clampPos(doc, pam.point);
|
||||
return mark === pam.mark && point === pam.point ? pam : { mark, point };
|
||||
}
|
||||
|
||||
export function docEnd(doc: SwDoc): SwPosition {
|
||||
const node = doc.nodes.length - 1;
|
||||
return { node, offset: nodeLength(doc.nodes[node]) };
|
||||
}
|
||||
92
desktop/src/sw/qa/core/wrtsh.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { getNodeText } from "../../inc/ndtxt";
|
||||
import { collapsed, pos } from "../../inc/pam";
|
||||
import { computeListLabels } from "../../source/core/doc/number";
|
||||
import { countDoc } from "../../source/core/doc/DocumentStatisticsManager";
|
||||
import { SwWrtShell } from "../../source/uibase/wrtsh/wrtsh";
|
||||
|
||||
const texts = (sh: SwWrtShell) => sh.doc.nodes.map(getNodeText);
|
||||
|
||||
function typed(text: string): SwWrtShell {
|
||||
const sh = new SwWrtShell();
|
||||
for (const ch of text) {
|
||||
if (ch === "\r") sh.splitNode();
|
||||
else sh.insert(ch);
|
||||
}
|
||||
return sh;
|
||||
}
|
||||
|
||||
describe("SwWrtShell editing", () => {
|
||||
it("types, splits and joins paragraphs", () => {
|
||||
const sh = typed("Hello\rWorld");
|
||||
expect(texts(sh)).toEqual(["Hello", "World"]);
|
||||
sh.setSelection(collapsed(pos(1, 0)));
|
||||
sh.delLeft();
|
||||
expect(texts(sh)).toEqual(["HelloWorld"]);
|
||||
expect(sh.pam.point).toEqual(pos(0, 5));
|
||||
sh.delRight();
|
||||
expect(texts(sh)).toEqual(["Helloorld"]);
|
||||
});
|
||||
|
||||
it("groups typing into one undo step", () => {
|
||||
const sh = typed("abc");
|
||||
sh.undo();
|
||||
expect(texts(sh)).toEqual([""]);
|
||||
sh.redo();
|
||||
expect(texts(sh)).toEqual(["abc"]);
|
||||
});
|
||||
|
||||
it("applies bold to the word around a collapsed cursor", () => {
|
||||
const sh = typed("one two");
|
||||
sh.setSelection(collapsed(pos(0, 5)));
|
||||
sh.toggleCharAttr("bold");
|
||||
const runs = sh.doc.nodes[0].runs;
|
||||
expect(runs.map((r) => [r.text, !!r.attrs.bold])).toEqual([
|
||||
["one ", false],
|
||||
["two", true],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps pending attributes for the next input", () => {
|
||||
const sh = typed("a");
|
||||
sh.toggleCharAttr("italic");
|
||||
sh.insert("b");
|
||||
expect(sh.doc.nodes[0].runs.map((r) => [r.text, !!r.attrs.italic])).toEqual([
|
||||
["a", false],
|
||||
["b", true],
|
||||
]);
|
||||
});
|
||||
|
||||
it("switches heading to body text on Enter at the end", () => {
|
||||
const sh = typed("Title");
|
||||
sh.setParaStyle("Heading_20_1");
|
||||
sh.splitNode();
|
||||
expect(sh.doc.nodes.map((n) => n.style)).toEqual(["Heading_20_1", "Text_20_body"]);
|
||||
});
|
||||
|
||||
it("numbers list paragraphs and leaves the list on empty Enter", () => {
|
||||
const sh = typed("a\rb");
|
||||
sh.selectAll();
|
||||
sh.toggleList("number");
|
||||
expect(computeListLabels(sh.doc)).toEqual(["1.", "2."]);
|
||||
sh.setSelection(collapsed(pos(1, 1)));
|
||||
sh.splitNode();
|
||||
sh.splitNode();
|
||||
expect(sh.doc.nodes[2].attrs.list).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pastes multi-line text as paragraphs", () => {
|
||||
const sh = typed("[]");
|
||||
sh.setSelection(collapsed(pos(0, 1)));
|
||||
sh.insertPlainText("x\ny\nz");
|
||||
expect(texts(sh)).toEqual(["[x", "y", "z]"]);
|
||||
expect(sh.pam.point).toEqual(pos(2, 1));
|
||||
});
|
||||
|
||||
it("finds and replaces all", () => {
|
||||
const sh = typed("cat Cat cat");
|
||||
expect(sh.replaceAll("cat", "dog", { matchCase: true })).toBe(2);
|
||||
expect(texts(sh)).toEqual(["dog Cat dog"]);
|
||||
expect(countDoc(sh.doc).words).toBe(3);
|
||||
});
|
||||
});
|
||||
57
desktop/src/sw/qa/filter/xml/roundtrip.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getNodeText } from "../../../inc/ndtxt";
|
||||
import { collapsed, pos } from "../../../inc/pam";
|
||||
import { importFlatOdt, importOdt } from "../../../source/filter/xml/swxml";
|
||||
import { exportFlatOdt, exportOdt } from "../../../source/filter/xml/wrtxml";
|
||||
import { SwWrtShell } from "../../../source/uibase/wrtsh/wrtsh";
|
||||
|
||||
function sample() {
|
||||
const sh = new SwWrtShell();
|
||||
sh.insert("Heading");
|
||||
sh.setParaStyle("Heading_20_1");
|
||||
sh.splitNode();
|
||||
sh.insert("Some bold\ttext");
|
||||
sh.setSelection({ mark: pos(1, 6), point: pos(1, 10) });
|
||||
sh.toggleCharAttr("bold");
|
||||
sh.setSelection(collapsed(pos(1, 15)));
|
||||
sh.splitNode();
|
||||
sh.insert("item one");
|
||||
sh.toggleList("bullet");
|
||||
sh.splitNode();
|
||||
sh.insert("nested");
|
||||
sh.changeIndent(1);
|
||||
sh.insertPageBreak();
|
||||
sh.insert("centered");
|
||||
sh.setParaAttrs({ adjust: "center" });
|
||||
return sh.doc;
|
||||
}
|
||||
|
||||
describe("ODF filters", () => {
|
||||
const doc = sample();
|
||||
|
||||
for (const [name, roundtrip] of [
|
||||
["fodt", () => importFlatOdt(exportFlatOdt(doc))],
|
||||
["odt", () => importOdt(exportOdt(doc))],
|
||||
] as const) {
|
||||
it(`round-trips ${name}`, () => {
|
||||
const back = roundtrip();
|
||||
expect(back.nodes.map(getNodeText)).toEqual(doc.nodes.map(getNodeText));
|
||||
expect(back.nodes.map((n) => n.style)).toEqual(doc.nodes.map((n) => n.style));
|
||||
expect(back.nodes[1].runs.map((r) => [r.text, !!r.attrs.bold])).toEqual([
|
||||
["Some ", false],
|
||||
["bold", true],
|
||||
["\ttext", false],
|
||||
]);
|
||||
expect(back.nodes.map((n) => [n.attrs.list, n.attrs.listLevel])).toEqual([
|
||||
[undefined, undefined],
|
||||
[undefined, undefined],
|
||||
["bullet", 1],
|
||||
["bullet", 2],
|
||||
["bullet", 2],
|
||||
]);
|
||||
expect(back.nodes[4].attrs.pageBreakBefore).toBe(true);
|
||||
expect(back.nodes[4].attrs.adjust).toBe("center");
|
||||
});
|
||||
}
|
||||
});
|
||||
73
desktop/src/sw/source/core/crsr/findtxt.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Text search — counterpart of sw/source/core/crsr/findtxt.cxx
|
||||
* (SwPaM::Find with i18nutil::SearchOptions2). Searches paragraph by
|
||||
* paragraph and wraps around the document.
|
||||
*/
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
import { getNodeText } from "../../../inc/ndtxt";
|
||||
import type { SwPaM, SwPosition } from "../../../inc/pam";
|
||||
|
||||
export interface SvxSearchOptions {
|
||||
matchCase?: boolean;
|
||||
wholeWords?: boolean;
|
||||
backwards?: boolean;
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function matcher(needle: string, opts: SvxSearchOptions): RegExp {
|
||||
const body = escapeRegExp(needle);
|
||||
return new RegExp(opts.wholeWords ? `(?<![\\p{L}\\p{N}_])${body}(?![\\p{L}\\p{N}_])` : body, opts.matchCase ? "gu" : "giu");
|
||||
}
|
||||
|
||||
function matchesIn(text: string, re: RegExp): [number, number][] {
|
||||
const out: [number, number][] = [];
|
||||
re.lastIndex = 0;
|
||||
for (let m = re.exec(text); m; m = re.exec(text)) {
|
||||
out.push([m.index, m.index + m[0].length]);
|
||||
if (m[0].length === 0) re.lastIndex++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Finds the next match after (or before, when backwards) `from`. */
|
||||
export function findText(
|
||||
doc: SwDoc,
|
||||
from: SwPosition,
|
||||
needle: string,
|
||||
opts: SvxSearchOptions = {},
|
||||
): SwPaM | null {
|
||||
if (!needle) return null;
|
||||
const re = matcher(needle, opts);
|
||||
const n = doc.nodes.length;
|
||||
for (let step = 0; step <= n; step++) {
|
||||
const idx = opts.backwards ? (from.node - step + n * 2) % n : (from.node + step) % n;
|
||||
const hits = matchesIn(getNodeText(doc.nodes[idx]), re);
|
||||
const ordered = opts.backwards ? hits.reverse() : hits;
|
||||
for (const [s, e] of ordered) {
|
||||
if (step === 0 && !opts.backwards && s < from.offset) continue;
|
||||
if (step === 0 && opts.backwards && e > from.offset) continue;
|
||||
if (step === n && !opts.backwards && s >= from.offset) continue;
|
||||
if (step === n && opts.backwards && e <= from.offset) continue;
|
||||
return opts.backwards
|
||||
? { mark: { node: idx, offset: e }, point: { node: idx, offset: s } }
|
||||
: { mark: { node: idx, offset: s }, point: { node: idx, offset: e } };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** All matches in document order. */
|
||||
export function findAll(doc: SwDoc, needle: string, opts: SvxSearchOptions = {}): SwPaM[] {
|
||||
if (!needle) return [];
|
||||
const re = matcher(needle, opts);
|
||||
const out: SwPaM[] = [];
|
||||
doc.nodes.forEach((node, idx) => {
|
||||
for (const [s, e] of matchesIn(getNodeText(node), re)) {
|
||||
out.push({ mark: { node: idx, offset: s }, point: { node: idx, offset: e } });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Text content operations — the TS counterpart of
|
||||
* sw/source/core/doc/DocumentContentOperationsManager.cxx (InsertString,
|
||||
* DeleteRange, SplitNode, AppendTextNode, CopyRange).
|
||||
*
|
||||
* Every operation is pure: it takes a document and returns a new one plus
|
||||
* the resulting cursor position.
|
||||
*/
|
||||
import type { SvxCharAttrs } from "../../../../editeng/source/items/textitem";
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
import { newNodeId, nodeLength, type SwTextNode, type SwTextRun } from "../../../inc/ndtxt";
|
||||
import { pamEnd, pamStart, type SwPaM, type SwPosition } from "../../../inc/pam";
|
||||
import { attrsAt, insertRuns, normalizeRuns, sliceRuns } from "../txtnode/thints";
|
||||
import { getParaStyle } from "./DocumentStylePoolManager";
|
||||
|
||||
export interface SwEditResult {
|
||||
readonly doc: SwDoc;
|
||||
readonly pos: SwPosition;
|
||||
}
|
||||
|
||||
function replaceNodes(
|
||||
doc: SwDoc,
|
||||
from: number,
|
||||
to: number,
|
||||
nodes: readonly SwTextNode[],
|
||||
): SwDoc {
|
||||
return { ...doc, nodes: [...doc.nodes.slice(0, from), ...nodes, ...doc.nodes.slice(to + 1)] };
|
||||
}
|
||||
|
||||
/** SwDoc::InsertString — inserts text inside one paragraph. */
|
||||
export function insertText(
|
||||
doc: SwDoc,
|
||||
at: SwPosition,
|
||||
text: string,
|
||||
attrs?: SvxCharAttrs,
|
||||
): SwEditResult {
|
||||
const node = doc.nodes[at.node];
|
||||
const runs = insertRuns(node.runs, at.offset, [
|
||||
{ text, attrs: attrs ?? attrsAt(node.runs, at.offset) },
|
||||
]);
|
||||
return {
|
||||
doc: replaceNodes(doc, at.node, at.node, [{ ...node, runs }]),
|
||||
pos: { node: at.node, offset: at.offset + text.length },
|
||||
};
|
||||
}
|
||||
|
||||
/** DeleteRange — removes the selected text, joining paragraphs as needed. */
|
||||
export function deleteRange(doc: SwDoc, pam: SwPaM): SwEditResult {
|
||||
const s = pamStart(pam);
|
||||
const e = pamEnd(pam);
|
||||
if (s.node === e.node && s.offset === e.offset) return { doc, pos: s };
|
||||
const first = doc.nodes[s.node];
|
||||
const last = doc.nodes[e.node];
|
||||
const runs = normalizeRuns([
|
||||
...sliceRuns(first.runs, 0, s.offset),
|
||||
...sliceRuns(last.runs, e.offset, nodeLength(last)),
|
||||
]);
|
||||
return { doc: replaceNodes(doc, s.node, e.node, [{ ...first, runs }]), pos: s };
|
||||
}
|
||||
|
||||
/** Splits a node's runs at `offset` without applying any "next style". */
|
||||
function splitRuns(node: SwTextNode, offset: number): [SwTextRun[], SwTextRun[]] {
|
||||
return [sliceRuns(node.runs, 0, offset), sliceRuns(node.runs, offset, nodeLength(node))];
|
||||
}
|
||||
|
||||
/** SwDoc::SplitNode — Enter. Applies the style's "next style" at the end. */
|
||||
export function splitNode(doc: SwDoc, at: SwPosition): SwEditResult {
|
||||
const node = doc.nodes[at.node];
|
||||
const len = nodeLength(node);
|
||||
const [head, tail] = splitRuns(node, at.offset);
|
||||
const style = getParaStyle(node.style);
|
||||
const atEnd = at.offset === len && len > 0;
|
||||
const { pageBreakBefore: _pb, ...tailAttrs } = node.attrs;
|
||||
const tailNode: SwTextNode = {
|
||||
id: newNodeId(),
|
||||
style: atEnd && style.next ? style.next : node.style,
|
||||
attrs: tailAttrs,
|
||||
runs: tail,
|
||||
};
|
||||
return {
|
||||
doc: replaceNodes(doc, at.node, at.node, [{ ...node, runs: head }, tailNode]),
|
||||
pos: { node: at.node + 1, offset: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a fragment of paragraphs at `at` (paste). The first fragment
|
||||
* paragraph is merged into the paragraph at the insert position; the last
|
||||
* one absorbs the text after it.
|
||||
*/
|
||||
export function insertNodes(
|
||||
doc: SwDoc,
|
||||
at: SwPosition,
|
||||
fragment: readonly SwTextNode[],
|
||||
): SwEditResult {
|
||||
if (fragment.length === 0) return { doc, pos: at };
|
||||
const node = doc.nodes[at.node];
|
||||
const [head, tail] = splitRuns(node, at.offset);
|
||||
const first = fragment[0];
|
||||
if (fragment.length === 1) {
|
||||
const runs = normalizeRuns([...head, ...first.runs, ...tail]);
|
||||
return {
|
||||
doc: replaceNodes(doc, at.node, at.node, [{ ...node, runs }]),
|
||||
pos: { node: at.node, offset: at.offset + nodeLength(first) },
|
||||
};
|
||||
}
|
||||
const last = fragment[fragment.length - 1];
|
||||
const nodes: SwTextNode[] = [
|
||||
{ ...node, runs: normalizeRuns([...head, ...first.runs]) },
|
||||
...fragment.slice(1, -1).map((n) => ({ ...n, id: newNodeId() })),
|
||||
{ ...last, id: newNodeId(), runs: normalizeRuns([...last.runs, ...tail]) },
|
||||
];
|
||||
return {
|
||||
doc: replaceNodes(doc, at.node, at.node, nodes),
|
||||
pos: { node: at.node + fragment.length - 1, offset: nodeLength(last) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Copies the selected range out as a paragraph fragment (for the clipboard). */
|
||||
export function copyRange(doc: SwDoc, pam: SwPaM): SwTextNode[] {
|
||||
const s = pamStart(pam);
|
||||
const e = pamEnd(pam);
|
||||
const out: SwTextNode[] = [];
|
||||
for (let i = s.node; i <= e.node; i++) {
|
||||
const n = doc.nodes[i];
|
||||
const from = i === s.node ? s.offset : 0;
|
||||
const to = i === e.node ? e.offset : nodeLength(n);
|
||||
out.push({ ...n, runs: sliceRuns(n.runs, from, to) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
47
desktop/src/sw/source/core/doc/DocumentStatisticsManager.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Document statistics (SwDocStat) — counterpart of
|
||||
* sw/source/core/doc/DocumentStatisticsManager.cxx, used by the status bar
|
||||
* word count and Tools > Word Count.
|
||||
*/
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
import { getNodeText } from "../../../inc/ndtxt";
|
||||
|
||||
export interface SwDocStat {
|
||||
words: number;
|
||||
chars: number;
|
||||
charsExclSpaces: number;
|
||||
paragraphs: number;
|
||||
}
|
||||
|
||||
const segmenter =
|
||||
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: "word" })
|
||||
: null;
|
||||
|
||||
export function countText(text: string): SwDocStat {
|
||||
let words = 0;
|
||||
if (segmenter) {
|
||||
for (const seg of segmenter.segment(text)) if (seg.isWordLike) words++;
|
||||
} else {
|
||||
words = text.split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
const chars = [...text].length;
|
||||
const charsExclSpaces = [...text.replace(/\s/g, "")].length;
|
||||
return { words, chars, charsExclSpaces, paragraphs: text.trim() ? 1 : 0 };
|
||||
}
|
||||
|
||||
export function countTexts(texts: readonly string[]): SwDocStat {
|
||||
const stat: SwDocStat = { words: 0, chars: 0, charsExclSpaces: 0, paragraphs: 0 };
|
||||
for (const t of texts) {
|
||||
const s = countText(t);
|
||||
stat.words += s.words;
|
||||
stat.chars += s.chars;
|
||||
stat.charsExclSpaces += s.charsExclSpaces;
|
||||
stat.paragraphs += s.paragraphs;
|
||||
}
|
||||
return stat;
|
||||
}
|
||||
|
||||
export function countDoc(doc: SwDoc): SwDocStat {
|
||||
return countTexts(doc.nodes.map(getNodeText));
|
||||
}
|
||||
148
desktop/src/sw/source/core/doc/DocumentStylePoolManager.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Paragraph style pool — mirrors sw/source/core/doc/DocumentStylePoolManager.cxx,
|
||||
* which creates Writer's built-in paragraph styles (SwTextFormatColl) on
|
||||
* demand. Names are the programmatic (ODF) names; `uiName` is what the UI
|
||||
* shows.
|
||||
*/
|
||||
import type { SvxCharAttrs } from "../../../../editeng/source/items/textitem";
|
||||
import type { SvxParaAttrs } from "../../../../editeng/source/items/paraitem";
|
||||
import type { SwTextNode } from "../../../inc/ndtxt";
|
||||
|
||||
export interface SwTextFormatColl {
|
||||
readonly name: string;
|
||||
readonly uiName: string;
|
||||
readonly parent?: string;
|
||||
/** Style given to the new paragraph when pressing Enter at the end. */
|
||||
readonly next?: string;
|
||||
/** Outline level for headings (text:h). */
|
||||
readonly outlineLevel?: number;
|
||||
readonly char: SvxCharAttrs;
|
||||
readonly para: SvxParaAttrs;
|
||||
}
|
||||
|
||||
export const DEFAULT_STYLE = "Standard";
|
||||
export const BODY_TEXT_STYLE = "Text_20_body";
|
||||
|
||||
export const PARA_STYLE_POOL: readonly SwTextFormatColl[] = [
|
||||
{
|
||||
name: DEFAULT_STYLE,
|
||||
uiName: "Default Paragraph Style",
|
||||
char: { fontName: "Liberation Serif", fontSize: 12 },
|
||||
para: { adjust: "left", spaceBefore: 0, spaceAfter: 0, lineHeight: 100 },
|
||||
},
|
||||
{
|
||||
name: BODY_TEXT_STYLE,
|
||||
uiName: "Body Text",
|
||||
parent: DEFAULT_STYLE,
|
||||
char: {},
|
||||
para: { spaceAfter: 7, lineHeight: 115 },
|
||||
},
|
||||
{
|
||||
name: "Heading",
|
||||
uiName: "Heading",
|
||||
parent: DEFAULT_STYLE,
|
||||
next: BODY_TEXT_STYLE,
|
||||
char: { fontName: "Liberation Sans", fontSize: 14 },
|
||||
para: { spaceBefore: 12, spaceAfter: 6 },
|
||||
},
|
||||
{
|
||||
name: "Heading_20_1",
|
||||
uiName: "Heading 1",
|
||||
parent: "Heading",
|
||||
next: BODY_TEXT_STYLE,
|
||||
outlineLevel: 1,
|
||||
char: { fontSize: 18, bold: true },
|
||||
para: {},
|
||||
},
|
||||
{
|
||||
name: "Heading_20_2",
|
||||
uiName: "Heading 2",
|
||||
parent: "Heading",
|
||||
next: BODY_TEXT_STYLE,
|
||||
outlineLevel: 2,
|
||||
char: { fontSize: 16, bold: true },
|
||||
para: { spaceBefore: 10 },
|
||||
},
|
||||
{
|
||||
name: "Heading_20_3",
|
||||
uiName: "Heading 3",
|
||||
parent: "Heading",
|
||||
next: BODY_TEXT_STYLE,
|
||||
outlineLevel: 3,
|
||||
char: { fontSize: 14, bold: true },
|
||||
para: { spaceBefore: 7 },
|
||||
},
|
||||
{
|
||||
name: "Title",
|
||||
uiName: "Title",
|
||||
parent: "Heading",
|
||||
next: "Subtitle",
|
||||
char: { fontSize: 28, bold: true },
|
||||
para: { adjust: "center" },
|
||||
},
|
||||
{
|
||||
name: "Subtitle",
|
||||
uiName: "Subtitle",
|
||||
parent: "Heading",
|
||||
next: BODY_TEXT_STYLE,
|
||||
char: { fontSize: 18 },
|
||||
para: { adjust: "center", spaceBefore: 3, spaceAfter: 6 },
|
||||
},
|
||||
{
|
||||
name: "Quotations",
|
||||
uiName: "Quotations",
|
||||
parent: DEFAULT_STYLE,
|
||||
char: {},
|
||||
para: { leftMargin: 1, rightMargin: 1, spaceAfter: 7 },
|
||||
},
|
||||
{
|
||||
name: "Preformatted_20_Text",
|
||||
uiName: "Preformatted Text",
|
||||
parent: DEFAULT_STYLE,
|
||||
char: { fontName: "Liberation Mono", fontSize: 10 },
|
||||
para: { spaceAfter: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
const byName = new Map(PARA_STYLE_POOL.map((s) => [s.name, s]));
|
||||
const byUiName = new Map(PARA_STYLE_POOL.map((s) => [s.uiName, s]));
|
||||
|
||||
export function getParaStyle(name: string): SwTextFormatColl {
|
||||
return byName.get(name) ?? byName.get(DEFAULT_STYLE)!;
|
||||
}
|
||||
|
||||
export function hasParaStyle(name: string): boolean {
|
||||
return byName.has(name);
|
||||
}
|
||||
|
||||
export function findParaStyleByUiName(uiName: string): SwTextFormatColl | undefined {
|
||||
return byUiName.get(uiName);
|
||||
}
|
||||
|
||||
interface ResolvedStyle {
|
||||
char: SvxCharAttrs;
|
||||
para: SvxParaAttrs;
|
||||
}
|
||||
|
||||
const resolved = new Map<string, ResolvedStyle>();
|
||||
|
||||
/** Style attributes with the parent chain applied (SwFormat::GetAttrSet). */
|
||||
export function resolveParaStyle(name: string): ResolvedStyle {
|
||||
let r = resolved.get(name);
|
||||
if (!r) {
|
||||
const style = getParaStyle(name);
|
||||
const base = style.parent ? resolveParaStyle(style.parent) : { char: {}, para: {} };
|
||||
r = { char: { ...base.char, ...style.char }, para: { ...base.para, ...style.para } };
|
||||
resolved.set(name, r);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Character attributes after applying the paragraph style underneath. */
|
||||
export function effectiveCharAttrs(style: string, direct: SvxCharAttrs): SvxCharAttrs {
|
||||
return { ...resolveParaStyle(style).char, ...direct };
|
||||
}
|
||||
|
||||
export function effectiveParaAttrs(node: SwTextNode): SvxParaAttrs {
|
||||
return { ...resolveParaStyle(node.style).para, ...node.attrs };
|
||||
}
|
||||
61
desktop/src/sw/source/core/doc/docfmt.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Attribute application — counterpart of sw/source/core/doc/docfmt.cxx
|
||||
* (InsertPoolItem / ResetAttrs / SetTextFormatColl).
|
||||
*/
|
||||
import type { SvxCharAttrs } from "../../../../editeng/source/items/textitem";
|
||||
import type { SvxParaAttrs } from "../../../../editeng/source/items/paraitem";
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
import { nodeLength, type SwTextNode } from "../../../inc/ndtxt";
|
||||
import { pamEnd, pamStart, type SwPaM } from "../../../inc/pam";
|
||||
import { mapRunAttrs } from "../txtnode/thints";
|
||||
|
||||
/** Rewrites the character attributes of every character in the selection. */
|
||||
export function formatChars(
|
||||
doc: SwDoc,
|
||||
pam: SwPaM,
|
||||
fn: (attrs: SvxCharAttrs) => SvxCharAttrs,
|
||||
): SwDoc {
|
||||
const s = pamStart(pam);
|
||||
const e = pamEnd(pam);
|
||||
const nodes = doc.nodes.slice();
|
||||
for (let i = s.node; i <= e.node; i++) {
|
||||
const n = nodes[i];
|
||||
const len = nodeLength(n);
|
||||
const from = i === s.node ? s.offset : 0;
|
||||
const to = i === e.node ? e.offset : len;
|
||||
if (from < to || len === 0) nodes[i] = { ...n, runs: mapRunAttrs(n.runs, from, to, fn) };
|
||||
}
|
||||
return { ...doc, nodes };
|
||||
}
|
||||
|
||||
/** Sets (or, with `undefined`, clears) character attributes on a selection. */
|
||||
export function setCharAttrs(doc: SwDoc, pam: SwPaM, patch: SvxCharAttrs): SwDoc {
|
||||
return formatChars(doc, pam, (a) => ({ ...a, ...patch }));
|
||||
}
|
||||
|
||||
/** Format > Clear Direct Formatting (character attributes only). */
|
||||
export function resetCharAttrs(doc: SwDoc, pam: SwPaM): SwDoc {
|
||||
return formatChars(doc, pam, () => ({}));
|
||||
}
|
||||
|
||||
/** Rewrites each paragraph touched by the selection. */
|
||||
export function formatParas(
|
||||
doc: SwDoc,
|
||||
pam: SwPaM,
|
||||
fn: (node: SwTextNode) => SwTextNode,
|
||||
): SwDoc {
|
||||
const s = pamStart(pam);
|
||||
const e = pamEnd(pam);
|
||||
const nodes = doc.nodes.slice();
|
||||
for (let i = s.node; i <= e.node; i++) nodes[i] = fn(nodes[i]);
|
||||
return { ...doc, nodes };
|
||||
}
|
||||
|
||||
export function setParaAttrs(doc: SwDoc, pam: SwPaM, patch: SvxParaAttrs): SwDoc {
|
||||
return formatParas(doc, pam, (n) => ({ ...n, attrs: { ...n.attrs, ...patch } }));
|
||||
}
|
||||
|
||||
/** SwDoc::SetTextFormatColl — applying a paragraph style. */
|
||||
export function setParaStyle(doc: SwDoc, pam: SwPaM, style: string): SwDoc {
|
||||
return formatParas(doc, pam, (n) => ({ ...n, style }));
|
||||
}
|
||||
14
desktop/src/sw/source/core/doc/docnew.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Document creation — counterpart of sw/source/core/doc/docnew.cxx
|
||||
* (SwDoc::SwDoc sets up an empty body with one paragraph).
|
||||
*/
|
||||
import { DEFAULT_PAGE_DESC, type SwDoc } from "../../../inc/doc";
|
||||
import { makeTextNode, type SwTextNode } from "../../../inc/ndtxt";
|
||||
import { DEFAULT_STYLE } from "./DocumentStylePoolManager";
|
||||
|
||||
export function createDoc(nodes: readonly SwTextNode[] = []): SwDoc {
|
||||
return {
|
||||
nodes: nodes.length > 0 ? nodes : [makeTextNode("", DEFAULT_STYLE)],
|
||||
pageDesc: DEFAULT_PAGE_DESC,
|
||||
};
|
||||
}
|
||||
31
desktop/src/sw/source/core/doc/number.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* List numbering — counterpart of sw/source/core/doc/number.cxx (SwNumRule).
|
||||
* Computes the label shown in front of each list paragraph. Consecutive list
|
||||
* paragraphs of the same kind form one list; a deeper level restarts when a
|
||||
* shallower item appears.
|
||||
*/
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
|
||||
const BULLETS = ["•", "◦", "▪"];
|
||||
|
||||
export function computeListLabels(doc: SwDoc): (string | undefined)[] {
|
||||
const labels: (string | undefined)[] = [];
|
||||
let counters: number[] = [];
|
||||
let kind: string | undefined;
|
||||
for (const node of doc.nodes) {
|
||||
const list = node.attrs.list;
|
||||
if (!list) {
|
||||
labels.push(undefined);
|
||||
counters = [];
|
||||
kind = undefined;
|
||||
continue;
|
||||
}
|
||||
if (list !== kind) counters = [];
|
||||
kind = list;
|
||||
const level = Math.max(1, node.attrs.listLevel ?? 1);
|
||||
counters.length = level;
|
||||
counters[level - 1] = (counters[level - 1] ?? 0) + 1;
|
||||
labels.push(list === "bullet" ? BULLETS[(level - 1) % BULLETS.length] : `${counters[level - 1]}.`);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
39
desktop/src/sw/source/core/layout/layact.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Pagination — a much-simplified SwLayAction (sw/source/core/layout/layact.cxx).
|
||||
* Upstream the layout splits text frames across pages line by line; this
|
||||
* skeleton moves whole paragraphs, using the heights measured from the DOM
|
||||
* by SwEditWin.
|
||||
*/
|
||||
import { CM_TO_PX, type SwDoc, type SwPageDesc } from "../../../inc/doc";
|
||||
|
||||
/** Height of the page's text area (SwPageFrame body) in CSS px. */
|
||||
export function bodyHeightPx(desc: SwPageDesc): number {
|
||||
return (desc.height - desc.marginTop - desc.marginBottom) * CM_TO_PX;
|
||||
}
|
||||
|
||||
/** Returns the index of the first paragraph on each page. */
|
||||
export function calcPageStarts(doc: SwDoc, heights: readonly number[]): number[] {
|
||||
const limit = bodyHeightPx(doc.pageDesc);
|
||||
const starts = [0];
|
||||
let y = 0;
|
||||
doc.nodes.forEach((node, i) => {
|
||||
const h = heights[i] ?? 0;
|
||||
if (i > 0 && (node.attrs.pageBreakBefore || (y > 0 && y + h > limit))) {
|
||||
starts.push(i);
|
||||
y = 0;
|
||||
}
|
||||
y += h;
|
||||
});
|
||||
return starts;
|
||||
}
|
||||
|
||||
/** Page (0-based) containing paragraph `node`. */
|
||||
export function pageOfNode(pageStarts: readonly number[], node: number): number {
|
||||
let page = 0;
|
||||
while (page + 1 < pageStarts.length && pageStarts[page + 1] <= node) page++;
|
||||
return page;
|
||||
}
|
||||
|
||||
export function sameStarts(a: readonly number[], b: readonly number[]): boolean {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
111
desktop/src/sw/source/core/layout/trvlfrm.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Mapping between view positions and model positions — the job of
|
||||
* SwLayoutFrame::GetModelPositionForViewPoint / SwTextFrame::GetCharRect in
|
||||
* sw/source/core/layout/trvlfrm.cxx. Here "view positions" are DOM
|
||||
* (node, offset) boundary points inside the frames rendered by SwTextFrame.
|
||||
*/
|
||||
import type { SwPaM, SwPosition } from "../../../inc/pam";
|
||||
|
||||
const PARA = "[data-sw-node]";
|
||||
|
||||
function paraIndex(el: Element): number {
|
||||
return Number(el.getAttribute("data-sw-node"));
|
||||
}
|
||||
|
||||
function textContainer(para: Element): Element {
|
||||
return para.querySelector(".sw-txt") ?? para;
|
||||
}
|
||||
|
||||
function textLength(el: Element): number {
|
||||
let n = 0;
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) n += walker.currentNode.nodeValue?.length ?? 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** DOM boundary point → model position, or null if outside the document. */
|
||||
export function domToPos(root: Element, node: Node, offset: number): SwPosition | null {
|
||||
if (!root.contains(node)) return null;
|
||||
const el = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
|
||||
const para = el?.closest(PARA);
|
||||
if (para && root.contains(para)) {
|
||||
const txt = textContainer(para);
|
||||
if (!txt.contains(node)) {
|
||||
// The paragraph box itself (outside its text): start or end.
|
||||
return { node: paraIndex(para), offset: offset === 0 ? 0 : textLength(txt) };
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.setStart(txt, 0);
|
||||
range.setEnd(node, offset);
|
||||
return { node: paraIndex(para), offset: range.toString().length };
|
||||
}
|
||||
// Between paragraphs (page boxes, gaps): snap to the next paragraph's
|
||||
// start, or the end of the last one.
|
||||
const point = document.createRange();
|
||||
point.setStart(node, offset);
|
||||
const paras = root.querySelectorAll(PARA);
|
||||
for (const p of Array.from(paras)) {
|
||||
if (point.comparePoint(p, 0) > 0) return { node: paraIndex(p), offset: 0 };
|
||||
}
|
||||
const last = paras[paras.length - 1];
|
||||
return last ? { node: paraIndex(last), offset: textLength(textContainer(last)) } : null;
|
||||
}
|
||||
|
||||
/** Model position → DOM boundary point. */
|
||||
export function posToDom(root: Element, pos: SwPosition): [Node, number] | null {
|
||||
const para = root.querySelector(`[data-sw-node="${pos.node}"]`);
|
||||
if (!para) return null;
|
||||
const txt = textContainer(para);
|
||||
const walker = document.createTreeWalker(txt, NodeFilter.SHOW_TEXT);
|
||||
let remaining = pos.offset;
|
||||
let last: Text | null = null;
|
||||
while (walker.nextNode()) {
|
||||
const t = walker.currentNode as Text;
|
||||
if (remaining <= t.length) return [t, remaining];
|
||||
remaining -= t.length;
|
||||
last = t;
|
||||
}
|
||||
return last ? [last, last.length] : [txt, 0];
|
||||
}
|
||||
|
||||
export function readDomSelection(root: Element): SwPaM | null {
|
||||
const sel = document.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || !sel.anchorNode || !sel.focusNode) return null;
|
||||
const mark = domToPos(root, sel.anchorNode, sel.anchorOffset);
|
||||
const point = domToPos(root, sel.focusNode, sel.focusOffset);
|
||||
return mark && point ? { mark, point } : null;
|
||||
}
|
||||
|
||||
export function writeDomSelection(root: Element, pam: SwPaM): void {
|
||||
const sel = document.getSelection();
|
||||
const a = posToDom(root, pam.mark);
|
||||
const f = posToDom(root, pam.point);
|
||||
if (sel && a && f) sel.setBaseAndExtent(a[0], a[1], f[0], f[1]);
|
||||
}
|
||||
|
||||
export function pamToRange(root: Element, pam: SwPaM): Range | null {
|
||||
const a = posToDom(root, pam.mark);
|
||||
const f = posToDom(root, pam.point);
|
||||
if (!a || !f) return null;
|
||||
const r = document.createRange();
|
||||
r.setStart(a[0], a[1]);
|
||||
r.setEnd(f[0], f[1]);
|
||||
if (r.collapsed && (a[0] !== f[0] || a[1] !== f[1])) {
|
||||
// mark was after point: swap
|
||||
r.setStart(f[0], f[1]);
|
||||
r.setEnd(a[0], a[1]);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Client rect of the cursor position (SwCursorShell::GetCharRect). */
|
||||
export function caretRect(root: Element, pos: SwPosition): DOMRect | null {
|
||||
const p = posToDom(root, pos);
|
||||
if (!p) return null;
|
||||
const r = document.createRange();
|
||||
r.setStart(p[0], p[1]);
|
||||
r.collapse(true);
|
||||
const rect = r.getBoundingClientRect();
|
||||
if (rect.height > 0) return rect;
|
||||
return root.querySelector(`[data-sw-node="${pos.node}"] .sw-txt`)?.getBoundingClientRect() ?? null;
|
||||
}
|
||||
86
desktop/src/sw/source/core/text/txtfrm.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* SwTextFrame — renders one paragraph (sw/source/core/text/txtfrm.cxx and the
|
||||
* portion painting in sw/source/core/text/). Upstream the frame formats
|
||||
* lines and paints glyphs itself; here the browser does line breaking and
|
||||
* this component translates Writer's attributes into CSS.
|
||||
*
|
||||
* DOM contract relied on by SwEditWin for selection mapping:
|
||||
* .sw-para[data-sw-node=<index>] > .sw-txt > (span text runs)* [br filler]
|
||||
*/
|
||||
import { memo, type CSSProperties } from "react";
|
||||
import type { SvxCharAttrs } from "../../../../editeng/source/items/textitem";
|
||||
import { LIST_INDENT_CM } from "../../../../editeng/source/items/paraitem";
|
||||
import { cssFontFamily } from "../../../../unotools/source/misc/fontdefs";
|
||||
import { getNodeText, type SwTextNode } from "../../../inc/ndtxt";
|
||||
import {
|
||||
effectiveCharAttrs,
|
||||
effectiveParaAttrs,
|
||||
} from "../doc/DocumentStylePoolManager";
|
||||
|
||||
/** Relative size of super/subscript text (DFLT_ESC_PROP). */
|
||||
const ESCAPEMENT_PROP = 0.58;
|
||||
/** "Single" line spacing of the Liberation fonts relative to the font size. */
|
||||
const SINGLE_LINE = 1.15;
|
||||
|
||||
export function charAttrsToCss(a: SvxCharAttrs): CSSProperties {
|
||||
const size = a.fontSize ?? 12;
|
||||
const esc = a.escapement && a.escapement !== "none" ? a.escapement : undefined;
|
||||
const deco = [a.underline && "underline", a.strikeout && "line-through"].filter(Boolean);
|
||||
return {
|
||||
fontFamily: cssFontFamily(a.fontName),
|
||||
fontSize: `${esc ? size * ESCAPEMENT_PROP : size}pt`,
|
||||
fontWeight: a.bold ? 700 : 400,
|
||||
fontStyle: a.italic ? "italic" : "normal",
|
||||
textDecorationLine: deco.length ? deco.join(" ") : "none",
|
||||
verticalAlign: esc ?? "baseline",
|
||||
color: a.color,
|
||||
backgroundColor: a.highlight,
|
||||
};
|
||||
}
|
||||
|
||||
interface SwTextFrameProps {
|
||||
node: SwTextNode;
|
||||
index: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const SwTextFrame = memo(function SwTextFrame({ node, index, label }: SwTextFrameProps) {
|
||||
const para = effectiveParaAttrs(node);
|
||||
const base = effectiveCharAttrs(node.style, {});
|
||||
const text = getNodeText(node);
|
||||
const listIndent = node.attrs.list ? (node.attrs.listLevel ?? 1) * LIST_INDENT_CM : 0;
|
||||
|
||||
const outer: CSSProperties = {
|
||||
paddingTop: `${para.spaceBefore ?? 0}pt`,
|
||||
paddingBottom: `${para.spaceAfter ?? 0}pt`,
|
||||
marginLeft: `${(para.leftMargin ?? 0) + listIndent}cm`,
|
||||
marginRight: `${para.rightMargin ?? 0}cm`,
|
||||
};
|
||||
const inner: CSSProperties = {
|
||||
...charAttrsToCss(base),
|
||||
textDecorationLine: "none",
|
||||
backgroundColor: undefined,
|
||||
textAlign: para.adjust === "block" ? "justify" : (para.adjust ?? "left"),
|
||||
lineHeight: ((para.lineHeight ?? 100) / 100) * SINGLE_LINE,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sw-para" data-sw-node={index} style={outer}>
|
||||
<div
|
||||
className="sw-txt"
|
||||
data-label={label}
|
||||
data-empty={text.length === 0 ? "" : undefined}
|
||||
style={inner}
|
||||
>
|
||||
{node.runs.map((run, i) =>
|
||||
run.text ? (
|
||||
<span key={i} style={charAttrsToCss(effectiveCharAttrs(node.style, run.attrs))}>
|
||||
{run.text}
|
||||
</span>
|
||||
) : null,
|
||||
)}
|
||||
{(text.length === 0 || text.endsWith("\n")) && <br data-sw-filler="" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
109
desktop/src/sw/source/core/txtnode/thints.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Attribute-run operations on a paragraph — the role SwpHints / thints.cxx
|
||||
* play upstream (inserting, splitting and merging text attributes).
|
||||
* Every function returns normalised runs (see ndtxt.ts).
|
||||
*/
|
||||
import {
|
||||
charAttrsEqual,
|
||||
normalizeCharAttrs,
|
||||
type SvxCharAttrs,
|
||||
} from "../../../../editeng/source/items/textitem";
|
||||
import type { SwTextRun } from "../../../inc/ndtxt";
|
||||
|
||||
export function normalizeRuns(runs: readonly SwTextRun[]): SwTextRun[] {
|
||||
const out: SwTextRun[] = [];
|
||||
for (const r of runs) {
|
||||
if (r.text === "") continue;
|
||||
const last = out[out.length - 1];
|
||||
if (last && charAttrsEqual(last.attrs, r.attrs)) {
|
||||
out[out.length - 1] = { text: last.text + r.text, attrs: last.attrs };
|
||||
} else {
|
||||
out.push({ text: r.text, attrs: normalizeCharAttrs(r.attrs) });
|
||||
}
|
||||
}
|
||||
if (out.length === 0) {
|
||||
// Keep the attributes of the (now empty) paragraph for the next input.
|
||||
return [{ text: "", attrs: normalizeCharAttrs(runs[0]?.attrs ?? {}) }];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function runsLength(runs: readonly SwTextRun[]): number {
|
||||
let n = 0;
|
||||
for (const r of runs) n += r.text.length;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Runs covering [start, end). An empty slice keeps the attrs at `start`. */
|
||||
export function sliceRuns(
|
||||
runs: readonly SwTextRun[],
|
||||
start: number,
|
||||
end: number,
|
||||
): SwTextRun[] {
|
||||
const out: SwTextRun[] = [];
|
||||
let at = 0;
|
||||
for (const r of runs) {
|
||||
const rs = at;
|
||||
const re = at + r.text.length;
|
||||
at = re;
|
||||
const s = Math.max(start, rs);
|
||||
const e = Math.min(end, re);
|
||||
if (s < e) out.push({ text: r.text.slice(s - rs, e - rs), attrs: r.attrs });
|
||||
}
|
||||
if (out.length === 0) return [{ text: "", attrs: attrsAt(runs, start) }];
|
||||
return normalizeRuns(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attributes in effect for input at `offset`: those of the character before
|
||||
* it, or of the first character when at the paragraph start (Writer's
|
||||
* behaviour when typing).
|
||||
*/
|
||||
export function attrsAt(runs: readonly SwTextRun[], offset: number): SvxCharAttrs {
|
||||
let at = 0;
|
||||
for (const r of runs) {
|
||||
const re = at + r.text.length;
|
||||
if (offset > at && offset <= re) return r.attrs;
|
||||
at = re;
|
||||
}
|
||||
return runs[0]?.attrs ?? {};
|
||||
}
|
||||
|
||||
/** Attributes of the character *at* `offset` (used for selection state). */
|
||||
export function attrsOfCharAt(runs: readonly SwTextRun[], offset: number): SvxCharAttrs {
|
||||
let at = 0;
|
||||
for (const r of runs) {
|
||||
const re = at + r.text.length;
|
||||
if (offset >= at && offset < re) return r.attrs;
|
||||
at = re;
|
||||
}
|
||||
return attrsAt(runs, offset);
|
||||
}
|
||||
|
||||
export function insertRuns(
|
||||
runs: readonly SwTextRun[],
|
||||
offset: number,
|
||||
inserted: readonly SwTextRun[],
|
||||
): SwTextRun[] {
|
||||
const total = runsLength(runs);
|
||||
const head = offset > 0 ? sliceRuns(runs, 0, offset) : [];
|
||||
const tail = offset < total ? sliceRuns(runs, offset, total) : [];
|
||||
return normalizeRuns([...head, ...inserted, ...tail]);
|
||||
}
|
||||
|
||||
/** Applies `fn` to the attributes of the characters in [start, end). */
|
||||
export function mapRunAttrs(
|
||||
runs: readonly SwTextRun[],
|
||||
start: number,
|
||||
end: number,
|
||||
fn: (attrs: SvxCharAttrs) => SvxCharAttrs,
|
||||
): SwTextRun[] {
|
||||
const total = runsLength(runs);
|
||||
if (total === 0) return normalizeRuns([{ text: "", attrs: fn(runs[0]?.attrs ?? {}) }]);
|
||||
const mid = sliceRuns(runs, start, end).map((r) => ({ text: r.text, attrs: fn(r.attrs) }));
|
||||
return normalizeRuns([
|
||||
...(start > 0 ? sliceRuns(runs, 0, start) : []),
|
||||
...mid,
|
||||
...(end < total ? sliceRuns(runs, end, total) : []),
|
||||
]);
|
||||
}
|
||||
69
desktop/src/sw/source/core/undo/UndoManager.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Undo/redo — counterpart of sw/source/core/undo/UndoManager.cxx.
|
||||
* Upstream records inverse actions (SwUndo subclasses); since SwDoc is
|
||||
* immutable here, an undo step is simply the state before the action.
|
||||
* Consecutive typing is grouped into one step, like SwUndoInsert merging.
|
||||
*/
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
import type { SwPaM } from "../../../inc/pam";
|
||||
|
||||
export interface SwUndoState {
|
||||
readonly doc: SwDoc;
|
||||
readonly pam: SwPaM;
|
||||
}
|
||||
|
||||
/** Matches the default "Number of steps" in Tools > Options > Memory. */
|
||||
const UNDO_LIMIT = 100;
|
||||
|
||||
export class SwUndoManager {
|
||||
private undoStack: { state: SwUndoState; comment: string }[] = [];
|
||||
private redoStack: { state: SwUndoState; comment: string }[] = [];
|
||||
private groupKind: string | null = null;
|
||||
|
||||
/**
|
||||
* Records `before` as an undo step. When `groupKind` is given and matches
|
||||
* the previous action, the step is merged into it instead.
|
||||
*/
|
||||
record(before: SwUndoState, comment: string, groupKind?: string): void {
|
||||
this.redoStack = [];
|
||||
if (groupKind && groupKind === this.groupKind) return;
|
||||
this.groupKind = groupKind ?? null;
|
||||
this.undoStack.push({ state: before, comment });
|
||||
if (this.undoStack.length > UNDO_LIMIT) this.undoStack.shift();
|
||||
}
|
||||
|
||||
/** Ends the current typing group (cursor moved, formatting changed, …). */
|
||||
breakGroup(): void {
|
||||
this.groupKind = null;
|
||||
}
|
||||
|
||||
undo(current: SwUndoState): SwUndoState | null {
|
||||
const step = this.undoStack.pop();
|
||||
if (!step) return null;
|
||||
this.groupKind = null;
|
||||
this.redoStack.push({ state: current, comment: step.comment });
|
||||
return step.state;
|
||||
}
|
||||
|
||||
redo(current: SwUndoState): SwUndoState | null {
|
||||
const step = this.redoStack.pop();
|
||||
if (!step) return null;
|
||||
this.groupKind = null;
|
||||
this.undoStack.push({ state: current, comment: step.comment });
|
||||
return step.state;
|
||||
}
|
||||
|
||||
get undoComment(): string | undefined {
|
||||
return this.undoStack[this.undoStack.length - 1]?.comment;
|
||||
}
|
||||
|
||||
get redoComment(): string | undefined {
|
||||
return this.redoStack[this.redoStack.length - 1]?.comment;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.groupKind = null;
|
||||
}
|
||||
}
|
||||
10
desktop/src/sw/source/filter/ascii/parasc.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** Plain-text import — counterpart of sw/source/filter/ascii/parasc.cxx. */
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
import { makeTextNode } from "../../../inc/ndtxt";
|
||||
import { DEFAULT_STYLE } from "../../core/doc/DocumentStylePoolManager";
|
||||
import { createDoc } from "../../core/doc/docnew";
|
||||
|
||||
export function importText(text: string): SwDoc {
|
||||
const lines = text.replace(/^/, "").replace(/\r\n?/g, "\n").replace(/\n$/, "").split("\n");
|
||||
return createDoc(lines.map((line) => makeTextNode(line, DEFAULT_STYLE)));
|
||||
}
|
||||
7
desktop/src/sw/source/filter/ascii/wrtasc.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/** Plain-text export — counterpart of sw/source/filter/ascii/wrtasc.cxx. */
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
import { getNodeText } from "../../../inc/ndtxt";
|
||||
|
||||
export function exportText(doc: SwDoc): string {
|
||||
return doc.nodes.map(getNodeText).join("\n") + "\n";
|
||||
}
|
||||
51
desktop/src/sw/source/filter/basflt/listtree.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Groups the flat paragraph array into nested lists, as the export filters
|
||||
* need (ODF text:list / HTML ul+ol). Upstream the equivalent bookkeeping
|
||||
* lives in each filter's list handling (e.g. XMLTextParagraphExport).
|
||||
*/
|
||||
import type { SwListKind } from "../../../../editeng/source/items/paraitem";
|
||||
import type { SwTextNode } from "../../../inc/ndtxt";
|
||||
|
||||
export type SwBlock =
|
||||
| { kind: "para"; node: SwTextNode }
|
||||
| { kind: "list"; list: SwListKind; items: SwBlock[][] };
|
||||
|
||||
const levelOf = (n: SwTextNode) => Math.max(1, n.attrs.listLevel ?? 1);
|
||||
|
||||
function buildList(
|
||||
nodes: readonly SwTextNode[],
|
||||
start: number,
|
||||
level: number,
|
||||
list: SwListKind,
|
||||
): [SwBlock, number] {
|
||||
const items: SwBlock[][] = [];
|
||||
let i = start;
|
||||
const inList = (j: number) => j < nodes.length && nodes[j].attrs.list === list && levelOf(nodes[j]) >= level;
|
||||
while (inList(i)) {
|
||||
const item: SwBlock[] = [];
|
||||
if (levelOf(nodes[i]) === level) item.push({ kind: "para", node: nodes[i++] });
|
||||
while (inList(i) && levelOf(nodes[i]) > level) {
|
||||
const [sub, next] = buildList(nodes, i, level + 1, list);
|
||||
item.push(sub);
|
||||
i = next;
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
return [{ kind: "list", list, items }, i];
|
||||
}
|
||||
|
||||
export function buildBlocks(nodes: readonly SwTextNode[]): SwBlock[] {
|
||||
const out: SwBlock[] = [];
|
||||
let i = 0;
|
||||
while (i < nodes.length) {
|
||||
const list = nodes[i].attrs.list;
|
||||
if (list) {
|
||||
const [block, next] = buildList(nodes, i, 1, list);
|
||||
out.push(block);
|
||||
i = next;
|
||||
} else {
|
||||
out.push({ kind: "para", node: nodes[i++] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
100
desktop/src/sw/source/filter/html/wrthtml.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* HTML export — counterpart of sw/source/filter/html/wrthtml.cxx
|
||||
* (SwHTMLWriter). Used for "Save As > HTML" and for the text/html flavour
|
||||
* on the clipboard.
|
||||
*/
|
||||
import type { SvxCharAttrs } from "../../../../editeng/source/items/textitem";
|
||||
import { cssFontFamily } from "../../../../unotools/source/misc/fontdefs";
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
import type { SwTextNode } from "../../../inc/ndtxt";
|
||||
import {
|
||||
effectiveParaAttrs,
|
||||
getParaStyle,
|
||||
} from "../../core/doc/DocumentStylePoolManager";
|
||||
import { buildBlocks, type SwBlock } from "../basflt/listtree";
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cssText(style: Record<string, unknown>): string {
|
||||
return Object.entries(style)
|
||||
.filter(([, v]) => v !== undefined && v !== "")
|
||||
.map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}: ${v}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/** CSS for the attributes set directly on a run (style formatting is implied by the tag/class). */
|
||||
function directCharCss(a: SvxCharAttrs): Record<string, string | undefined> {
|
||||
const deco = [a.underline && "underline", a.strikeout && "line-through"].filter(Boolean).join(" ");
|
||||
const flag = (v: boolean | undefined, on: string, off: string) => (v === undefined ? undefined : v ? on : off);
|
||||
return {
|
||||
fontFamily: a.fontName ? cssFontFamily(a.fontName) : undefined,
|
||||
fontSize: a.fontSize ? `${a.fontSize}pt` : undefined,
|
||||
fontWeight: flag(a.bold, "bold", "normal"),
|
||||
fontStyle: flag(a.italic, "italic", "normal"),
|
||||
textDecorationLine: deco || undefined,
|
||||
verticalAlign: a.escapement && a.escapement !== "none" ? a.escapement : undefined,
|
||||
color: a.color,
|
||||
backgroundColor: a.highlight,
|
||||
};
|
||||
}
|
||||
|
||||
const TAGS: Record<string, string> = {
|
||||
Heading_20_1: "h1",
|
||||
Heading_20_2: "h2",
|
||||
Heading_20_3: "h3",
|
||||
Title: "h1",
|
||||
Preformatted_20_Text: "pre",
|
||||
};
|
||||
|
||||
function paraHtml(node: SwTextNode): string {
|
||||
const tag = TAGS[node.style] ?? "p";
|
||||
const para = effectiveParaAttrs(node);
|
||||
const style = cssText({
|
||||
textAlign: para.adjust === "block" ? "justify" : para.adjust !== "left" ? para.adjust : undefined,
|
||||
marginLeft: para.leftMargin && !node.attrs.list ? `${para.leftMargin}cm` : undefined,
|
||||
});
|
||||
const inner = node.runs
|
||||
.filter((r) => r.text)
|
||||
.map((r) => {
|
||||
const text = esc(r.text).replace(/\n/g, "<br>");
|
||||
const css = cssText(directCharCss(r.attrs));
|
||||
return css ? `<span style="${esc(css)}">${text}</span>` : text;
|
||||
})
|
||||
.join("");
|
||||
const cls = getParaStyle(node.style).uiName;
|
||||
return `<${tag} class="${esc(cls)}"${style ? ` style="${esc(style)}"` : ""}>${inner || "<br>"}</${tag}>`;
|
||||
}
|
||||
|
||||
function blockHtml(block: SwBlock): string {
|
||||
if (block.kind === "para") return paraHtml(block.node);
|
||||
const tag = block.list === "number" ? "ol" : "ul";
|
||||
return `<${tag}>${block.items.map((item) => `<li>${item.map(blockHtml).join("")}</li>`).join("")}</${tag}>`;
|
||||
}
|
||||
|
||||
/** The body markup only (clipboard). */
|
||||
export function exportHtmlFragment(nodes: readonly SwTextNode[]): string {
|
||||
return buildBlocks(nodes).map(blockHtml).join("\n");
|
||||
}
|
||||
|
||||
export function exportHtml(doc: SwDoc, title = "Document"): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="generator" content="LibreOfficeJS/0.1">
|
||||
<title>${esc(title)}</title>
|
||||
<style>
|
||||
body { font-family: "Liberation Serif", "Times New Roman", serif; font-size: 12pt; max-width: ${doc.pageDesc.width - doc.pageDesc.marginLeft - doc.pageDesc.marginRight}cm; margin: 2cm auto; }
|
||||
h1, h2, h3, .Title, .Subtitle { font-family: "Liberation Sans", Arial, sans-serif; }
|
||||
.Title, .Subtitle { text-align: center; }
|
||||
p { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${exportHtmlFragment(doc.nodes)}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
295
desktop/src/sw/source/filter/xml/swxml.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
/**
|
||||
* ODF import — counterpart of sw/source/filter/xml/swxml.cxx (SwXMLReader)
|
||||
* with the text import from xmloff. Reads .odt packages and flat .fodt
|
||||
* files. Paragraph styles Writer knows are kept as styles; formatting from
|
||||
* automatic and unknown custom styles becomes direct formatting.
|
||||
*
|
||||
* Not yet imported: tables (their paragraphs are flattened), frames/images,
|
||||
* fields (their current text is kept), notes and tracked changes.
|
||||
*/
|
||||
import { strFromU8, unzipSync } from "fflate";
|
||||
import type { SvxCharAttrs } from "../../../../editeng/source/items/textitem";
|
||||
import type { SvxParaAttrs, SwListKind } from "../../../../editeng/source/items/paraitem";
|
||||
import { DEFAULT_PAGE_DESC, type SwDoc, type SwPageDesc } from "../../../inc/doc";
|
||||
import { newNodeId, type SwTextNode, type SwTextRun } from "../../../inc/ndtxt";
|
||||
import { DEFAULT_STYLE, hasParaStyle } from "../../core/doc/DocumentStylePoolManager";
|
||||
import { normalizeRuns } from "../../core/txtnode/thints";
|
||||
import { NS } from "./xmlnmspe";
|
||||
|
||||
interface OdfStyle {
|
||||
parent?: string;
|
||||
para: SvxParaAttrs;
|
||||
char: SvxCharAttrs;
|
||||
}
|
||||
|
||||
interface ImportContext {
|
||||
paraStyles: Map<string, OdfStyle>;
|
||||
textStyles: Map<string, OdfStyle>;
|
||||
listStyles: Map<string, SwListKind[]>;
|
||||
fonts: Map<string, string>;
|
||||
pageDesc: SwPageDesc;
|
||||
}
|
||||
|
||||
const attr = (el: Element, ns: string, name: string) => el.getAttributeNS(ns, name) ?? undefined;
|
||||
const children = (el: Element) => Array.from(el.children);
|
||||
const is = (el: Element, ns: string, local: string) => el.namespaceURI === ns && el.localName === local;
|
||||
|
||||
/** Parses an ODF length ("2cm", "0.5in", "12pt") into cm. */
|
||||
export function lengthToCm(v: string | undefined): number | undefined {
|
||||
const m = v?.match(/^(-?[\d.]+)(cm|mm|in|pt|pc|px)$/);
|
||||
if (!m) return undefined;
|
||||
const n = parseFloat(m[1]);
|
||||
const factor = { cm: 1, mm: 0.1, in: 2.54, pt: 2.54 / 72, pc: 2.54 / 6, px: 2.54 / 96 }[m[2]]!;
|
||||
return n * factor;
|
||||
}
|
||||
|
||||
const cmToPt = (cm: number | undefined) => (cm === undefined ? undefined : +(cm * (72 / 2.54)).toFixed(2));
|
||||
|
||||
function parseParaProps(el: Element | undefined): SvxParaAttrs {
|
||||
if (!el) return {};
|
||||
const p: SvxParaAttrs = {};
|
||||
const align = attr(el, NS.fo, "text-align");
|
||||
if (align) {
|
||||
p.adjust = ({ start: "left", left: "left", end: "right", right: "right", center: "center", justify: "block" } as const)[
|
||||
align as "start"
|
||||
];
|
||||
}
|
||||
const ml = lengthToCm(attr(el, NS.fo, "margin-left"));
|
||||
if (ml !== undefined) p.leftMargin = +ml.toFixed(3);
|
||||
const mr = lengthToCm(attr(el, NS.fo, "margin-right"));
|
||||
if (mr !== undefined) p.rightMargin = +mr.toFixed(3);
|
||||
const mt = cmToPt(lengthToCm(attr(el, NS.fo, "margin-top")));
|
||||
if (mt !== undefined) p.spaceBefore = mt;
|
||||
const mb = cmToPt(lengthToCm(attr(el, NS.fo, "margin-bottom")));
|
||||
if (mb !== undefined) p.spaceAfter = mb;
|
||||
const lh = attr(el, NS.fo, "line-height")?.match(/^([\d.]+)%$/);
|
||||
if (lh) p.lineHeight = parseFloat(lh[1]);
|
||||
if (attr(el, NS.fo, "break-before") === "page") p.pageBreakBefore = true;
|
||||
return p;
|
||||
}
|
||||
|
||||
function parseTextProps(el: Element | undefined, fonts: Map<string, string>): SvxCharAttrs {
|
||||
if (!el) return {};
|
||||
const c: SvxCharAttrs = {};
|
||||
const fontName = attr(el, NS.style, "font-name");
|
||||
const family = attr(el, NS.fo, "font-family");
|
||||
if (fontName) c.fontName = fonts.get(fontName) ?? fontName;
|
||||
else if (family) c.fontName = family.replace(/^['"]|['"]$/g, "");
|
||||
const size = attr(el, NS.fo, "font-size")?.match(/^([\d.]+)pt$/);
|
||||
if (size) c.fontSize = parseFloat(size[1]);
|
||||
const weight = attr(el, NS.fo, "font-weight");
|
||||
if (weight) c.bold = weight === "bold" || parseInt(weight, 10) >= 600;
|
||||
const style = attr(el, NS.fo, "font-style");
|
||||
if (style) c.italic = style === "italic" || style === "oblique";
|
||||
const ul = attr(el, NS.style, "text-underline-style");
|
||||
if (ul) c.underline = ul !== "none";
|
||||
const lt = attr(el, NS.style, "text-line-through-style");
|
||||
if (lt) c.strikeout = lt !== "none";
|
||||
const position = attr(el, NS.style, "text-position");
|
||||
if (position) {
|
||||
const first = position.split(/\s+/)[0];
|
||||
c.escapement =
|
||||
first === "super" || parseFloat(first) > 0 ? "super" : first === "sub" || parseFloat(first) < 0 ? "sub" : "none";
|
||||
}
|
||||
const color = attr(el, NS.fo, "color");
|
||||
if (color?.startsWith("#")) c.color = color;
|
||||
const bg = attr(el, NS.fo, "background-color");
|
||||
if (bg?.startsWith("#")) c.highlight = bg;
|
||||
return c;
|
||||
}
|
||||
|
||||
function readStyles(docs: Document[]): ImportContext {
|
||||
const ctx: ImportContext = {
|
||||
paraStyles: new Map(),
|
||||
textStyles: new Map(),
|
||||
listStyles: new Map(),
|
||||
fonts: new Map(),
|
||||
pageDesc: DEFAULT_PAGE_DESC,
|
||||
};
|
||||
for (const doc of docs) {
|
||||
for (const el of Array.from(doc.getElementsByTagNameNS(NS.style, "font-face"))) {
|
||||
const name = attr(el, NS.style, "name");
|
||||
const family = attr(el, NS.svg, "font-family");
|
||||
if (name) ctx.fonts.set(name, (family ?? name).replace(/^['"]|['"]$/g, ""));
|
||||
}
|
||||
}
|
||||
for (const doc of docs) {
|
||||
for (const el of Array.from(doc.getElementsByTagNameNS(NS.style, "style"))) {
|
||||
const name = attr(el, NS.style, "name");
|
||||
if (!name) continue;
|
||||
const kids = children(el);
|
||||
const style: OdfStyle = {
|
||||
parent: attr(el, NS.style, "parent-style-name"),
|
||||
para: parseParaProps(kids.find((k) => is(k, NS.style, "paragraph-properties"))),
|
||||
char: parseTextProps(kids.find((k) => is(k, NS.style, "text-properties")), ctx.fonts),
|
||||
};
|
||||
const family = attr(el, NS.style, "family");
|
||||
if (family === "paragraph") ctx.paraStyles.set(name, style);
|
||||
else if (family === "text") ctx.textStyles.set(name, style);
|
||||
}
|
||||
for (const el of Array.from(doc.getElementsByTagNameNS(NS.text, "list-style"))) {
|
||||
const name = attr(el, NS.style, "name");
|
||||
if (!name) continue;
|
||||
const levels: SwListKind[] = [];
|
||||
for (const lvl of children(el)) {
|
||||
const n = parseInt(attr(lvl, NS.text, "level") ?? "1", 10);
|
||||
levels[n - 1] = lvl.localName === "list-level-style-bullet" ? "bullet" : "number";
|
||||
}
|
||||
ctx.listStyles.set(name, levels);
|
||||
}
|
||||
const layout = doc.getElementsByTagNameNS(NS.style, "page-layout-properties")[0];
|
||||
if (layout) {
|
||||
const cm = (n: string, dflt: number) => lengthToCm(attr(layout, NS.fo, n)) ?? dflt;
|
||||
const d = DEFAULT_PAGE_DESC;
|
||||
ctx.pageDesc = {
|
||||
name: d.name,
|
||||
width: cm("page-width", d.width),
|
||||
height: cm("page-height", d.height),
|
||||
marginTop: cm("margin-top", d.marginTop),
|
||||
marginBottom: cm("margin-bottom", d.marginBottom),
|
||||
marginLeft: cm("margin-left", d.marginLeft),
|
||||
marginRight: cm("margin-right", d.marginRight),
|
||||
};
|
||||
}
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a paragraph style's parent chain up to a style Writer knows,
|
||||
* collecting the formatting of the styles in between as direct attributes.
|
||||
*/
|
||||
function resolveParaStyle(ctx: ImportContext, name: string | undefined) {
|
||||
const chain: OdfStyle[] = [];
|
||||
let cur = name;
|
||||
for (let guard = 0; cur && guard < 32; guard++) {
|
||||
if (hasParaStyle(cur)) break;
|
||||
const s = ctx.paraStyles.get(cur);
|
||||
if (!s) {
|
||||
cur = undefined;
|
||||
break;
|
||||
}
|
||||
chain.unshift(s);
|
||||
cur = s.parent;
|
||||
}
|
||||
const para: SvxParaAttrs = {};
|
||||
const char: SvxCharAttrs = {};
|
||||
for (const s of chain) {
|
||||
Object.assign(para, s.para);
|
||||
Object.assign(char, s.char);
|
||||
}
|
||||
return { style: cur ?? DEFAULT_STYLE, para, char };
|
||||
}
|
||||
|
||||
function resolveTextStyle(ctx: ImportContext, name: string | undefined): SvxCharAttrs {
|
||||
const chain: OdfStyle[] = [];
|
||||
for (let cur = name, guard = 0; cur && guard < 32; guard++) {
|
||||
const s = ctx.textStyles.get(cur);
|
||||
if (!s) break;
|
||||
chain.unshift(s);
|
||||
cur = s.parent;
|
||||
}
|
||||
return Object.assign({}, ...chain.map((s) => s.char));
|
||||
}
|
||||
|
||||
interface ListState {
|
||||
kind: SwListKind;
|
||||
level: number;
|
||||
}
|
||||
|
||||
function importParagraph(ctx: ImportContext, el: Element, list: ListState | null): SwTextNode {
|
||||
const { style, para, char } = resolveParaStyle(ctx, attr(el, NS.text, "style-name"));
|
||||
const runs: SwTextRun[] = [];
|
||||
let lastSpace = true;
|
||||
const push = (text: string, attrs: SvxCharAttrs) => {
|
||||
if (text) runs.push({ text, attrs });
|
||||
};
|
||||
const walk = (parent: Element, attrs: SvxCharAttrs) => {
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
if (child.nodeType === 3 /* TEXT_NODE */) {
|
||||
let t = (child.nodeValue ?? "").replace(/[ \t\r\n]+/g, " ");
|
||||
if (lastSpace) t = t.replace(/^ /, "");
|
||||
if (t) lastSpace = t.endsWith(" ");
|
||||
push(t, attrs);
|
||||
continue;
|
||||
}
|
||||
if (child.nodeType !== 1 /* ELEMENT_NODE */) continue;
|
||||
const c = child as Element;
|
||||
if (is(c, NS.text, "s")) {
|
||||
push(" ".repeat(parseInt(attr(c, NS.text, "c") ?? "1", 10) || 1), attrs);
|
||||
lastSpace = true;
|
||||
} else if (is(c, NS.text, "tab")) {
|
||||
push("\t", attrs);
|
||||
lastSpace = true;
|
||||
} else if (is(c, NS.text, "line-break")) {
|
||||
push("\n", attrs);
|
||||
lastSpace = true;
|
||||
} else if (is(c, NS.text, "span")) {
|
||||
walk(c, { ...attrs, ...resolveTextStyle(ctx, attr(c, NS.text, "style-name")) });
|
||||
} else if (
|
||||
is(c, NS.text, "note") ||
|
||||
is(c, NS.office, "annotation") ||
|
||||
c.namespaceURI === "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
|
||||
) {
|
||||
// Footnotes, comments and frames are not supported yet.
|
||||
} else {
|
||||
walk(c, attrs); // text:a, fields, bookmarks, …
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(el, char);
|
||||
const attrs: SvxParaAttrs = { ...para };
|
||||
if (list) {
|
||||
attrs.list = list.kind;
|
||||
attrs.listLevel = list.level;
|
||||
}
|
||||
return { id: newNodeId(), style, attrs, runs: normalizeRuns(runs.length ? runs : [{ text: "", attrs: char }]) };
|
||||
}
|
||||
|
||||
function importBody(ctx: ImportContext, parent: Element, out: SwTextNode[], list: ListState | null, listStyle?: string): void {
|
||||
for (const el of children(parent)) {
|
||||
if (is(el, NS.text, "p") || is(el, NS.text, "h")) {
|
||||
out.push(importParagraph(ctx, el, list));
|
||||
} else if (is(el, NS.text, "list")) {
|
||||
const styleName = attr(el, NS.text, "style-name") || listStyle;
|
||||
const level = (list?.level ?? 0) + 1;
|
||||
const kind = (styleName && ctx.listStyles.get(styleName)?.[level - 1]) || list?.kind || "bullet";
|
||||
for (const item of children(el)) importBody(ctx, item, out, { kind, level }, styleName);
|
||||
} else if (
|
||||
is(el, NS.text, "sequence-decls") ||
|
||||
is(el, NS.text, "tracked-changes") ||
|
||||
is(el, NS.office, "forms") ||
|
||||
is(el, NS.table, "table-columns")
|
||||
) {
|
||||
// Metadata without body text.
|
||||
} else {
|
||||
importBody(ctx, el, out, list, listStyle); // sections, tables, indexes, …
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function importDocuments(content: Document, styleDocs: Document[]): SwDoc {
|
||||
const err = content.getElementsByTagName("parsererror")[0];
|
||||
if (err) throw new Error(`Malformed ODF XML: ${err.textContent ?? ""}`);
|
||||
const ctx = readStyles([...styleDocs, content]);
|
||||
const text = content.getElementsByTagNameNS(NS.office, "text")[0];
|
||||
if (!text) throw new Error("Not an ODF text document (no office:text body).");
|
||||
const nodes: SwTextNode[] = [];
|
||||
importBody(ctx, text, nodes, null);
|
||||
if (nodes.length === 0) nodes.push({ id: newNodeId(), style: DEFAULT_STYLE, attrs: {}, runs: [{ text: "", attrs: {} }] });
|
||||
return { nodes, pageDesc: ctx.pageDesc };
|
||||
}
|
||||
|
||||
const parse = (xml: string) => new DOMParser().parseFromString(xml, "application/xml");
|
||||
|
||||
export function importFlatOdt(xml: string): SwDoc {
|
||||
const doc = parse(xml);
|
||||
return importDocuments(doc, []);
|
||||
}
|
||||
|
||||
export function importOdt(bytes: Uint8Array): SwDoc {
|
||||
const files = unzipSync(bytes, { filter: (f) => f.name === "content.xml" || f.name === "styles.xml" });
|
||||
if (!files["content.xml"]) throw new Error("Not an ODF package (content.xml missing).");
|
||||
const styles = files["styles.xml"] ? [parse(strFromU8(files["styles.xml"]))] : [];
|
||||
return importDocuments(parse(strFromU8(files["content.xml"])), styles);
|
||||
}
|
||||
306
desktop/src/sw/source/filter/xml/wrtxml.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
/**
|
||||
* ODF export — counterpart of sw/source/filter/xml/wrtxml.cxx (SwXMLWriter)
|
||||
* driving xmloff's text export. Produces either a zipped .odt package or a
|
||||
* single-file flat .fodt document.
|
||||
*/
|
||||
import { strToU8, zipSync, type Zippable } from "fflate";
|
||||
import type { SvxCharAttrs } from "../../../../editeng/source/items/textitem";
|
||||
import {
|
||||
LIST_INDENT_CM,
|
||||
MAX_LIST_LEVEL,
|
||||
type SvxParaAttrs,
|
||||
type SwListKind,
|
||||
} from "../../../../editeng/source/items/paraitem";
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
import { getNodeText, type SwTextNode } from "../../../inc/ndtxt";
|
||||
import {
|
||||
getParaStyle,
|
||||
PARA_STYLE_POOL,
|
||||
} from "../../core/doc/DocumentStylePoolManager";
|
||||
import { buildBlocks, type SwBlock } from "../basflt/listtree";
|
||||
import { NS_DECLS, ODT_MIMETYPE } from "./xmlnmspe";
|
||||
|
||||
const GENERATOR = "LibreOfficeJS/0.1";
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
const num = (v: number) => String(+v.toFixed(4));
|
||||
const attrs = (a: Record<string, string | undefined>) =>
|
||||
Object.entries(a)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k, v]) => ` ${k}="${esc(v!)}"`)
|
||||
.join("");
|
||||
|
||||
const ALIGN: Record<string, string> = { left: "start", center: "center", right: "end", block: "justify" };
|
||||
|
||||
function paraProps(p: SvxParaAttrs, includeList: boolean): string {
|
||||
const a = attrs({
|
||||
"fo:text-align": p.adjust && ALIGN[p.adjust],
|
||||
"fo:margin-left":
|
||||
p.leftMargin !== undefined && !(includeList && p.list) ? `${num(p.leftMargin)}cm` : undefined,
|
||||
"fo:margin-right": p.rightMargin !== undefined ? `${num(p.rightMargin)}cm` : undefined,
|
||||
"fo:margin-top": p.spaceBefore !== undefined ? `${num(p.spaceBefore)}pt` : undefined,
|
||||
"fo:margin-bottom": p.spaceAfter !== undefined ? `${num(p.spaceAfter)}pt` : undefined,
|
||||
"fo:line-height": p.lineHeight !== undefined ? `${num(p.lineHeight)}%` : undefined,
|
||||
"fo:break-before": p.pageBreakBefore ? "page" : undefined,
|
||||
});
|
||||
return a ? `<style:paragraph-properties${a}/>` : "";
|
||||
}
|
||||
|
||||
function textProps(c: SvxCharAttrs): string {
|
||||
const bool = (v: boolean | undefined, on: string, off: string) =>
|
||||
v === undefined ? undefined : v ? on : off;
|
||||
const a = attrs({
|
||||
"style:font-name": c.fontName,
|
||||
"fo:font-size": c.fontSize !== undefined ? `${num(c.fontSize)}pt` : undefined,
|
||||
"fo:font-weight": bool(c.bold, "bold", "normal"),
|
||||
"fo:font-style": bool(c.italic, "italic", "normal"),
|
||||
"style:text-underline-style": bool(c.underline, "solid", "none"),
|
||||
"style:text-underline-width": c.underline ? "auto" : undefined,
|
||||
"style:text-underline-color": c.underline ? "font-color" : undefined,
|
||||
"style:text-line-through-style": bool(c.strikeout, "solid", "none"),
|
||||
"style:text-position":
|
||||
c.escapement === "super" ? "super 58%" : c.escapement === "sub" ? "sub 58%" : c.escapement === "none" ? "0% 100%" : undefined,
|
||||
"fo:color": c.color,
|
||||
"fo:background-color": c.highlight,
|
||||
});
|
||||
return a ? `<style:text-properties${a}/>` : "";
|
||||
}
|
||||
|
||||
/** Assigns automatic style names (P1, T1, …) to distinct attribute sets. */
|
||||
class AutoStylePool {
|
||||
private readonly byKey = new Map<string, string>();
|
||||
readonly xml: string[] = [];
|
||||
constructor(private readonly prefix: string) {}
|
||||
get(key: string, build: (name: string) => string): string {
|
||||
let name = this.byKey.get(key);
|
||||
if (!name) {
|
||||
name = `${this.prefix}${this.byKey.size + 1}`;
|
||||
this.byKey.set(key, name);
|
||||
this.xml.push(build(name));
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
function listStyleXml(name: string, kind: SwListKind): string {
|
||||
let levels = "";
|
||||
for (let l = 1; l <= MAX_LIST_LEVEL; l++) {
|
||||
const indent = `${num(l * LIST_INDENT_CM)}cm`;
|
||||
const props =
|
||||
`<style:list-level-properties text:list-level-position-and-space-mode="label-alignment">` +
|
||||
`<style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="${indent}" fo:text-indent="-${num(LIST_INDENT_CM)}cm" fo:margin-left="${indent}"/>` +
|
||||
`</style:list-level-properties>`;
|
||||
levels +=
|
||||
kind === "bullet"
|
||||
? `<text:list-level-style-bullet text:level="${l}" text:bullet-char="${["•", "◦", "▪"][(l - 1) % 3]}">${props}</text:list-level-style-bullet>`
|
||||
: `<text:list-level-style-number text:level="${l}" style:num-suffix="." style:num-format="1">${props}</text:list-level-style-number>`;
|
||||
}
|
||||
return `<text:list-style style:name="${name}">${levels}</text:list-style>`;
|
||||
}
|
||||
|
||||
/** Writes paragraph text with ODF whitespace handling (text:s, text:tab, …). */
|
||||
class TextWriter {
|
||||
private prevSpace = true; // leading spaces must be written as text:s
|
||||
out = "";
|
||||
write(text: string): void {
|
||||
let spaces = 0;
|
||||
const flushSpaces = () => {
|
||||
if (spaces > 0) {
|
||||
this.out += spaces === 1 ? "<text:s/>" : `<text:s text:c="${spaces}"/>`;
|
||||
spaces = 0;
|
||||
}
|
||||
};
|
||||
for (const ch of text) {
|
||||
if (ch === " ") {
|
||||
if (this.prevSpace) spaces++;
|
||||
else this.out += " ";
|
||||
this.prevSpace = true;
|
||||
continue;
|
||||
}
|
||||
flushSpaces();
|
||||
if (ch === "\t") this.out += "<text:tab/>";
|
||||
else if (ch === "\n") this.out += "<text:line-break/>";
|
||||
else this.out += esc(ch);
|
||||
this.prevSpace = ch === "\t" || ch === "\n";
|
||||
}
|
||||
flushSpaces();
|
||||
}
|
||||
}
|
||||
|
||||
interface OdfParts {
|
||||
fontDecls: string;
|
||||
styles: string;
|
||||
stylesAutomatic: string;
|
||||
masterStyles: string;
|
||||
contentAutomatic: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function collectFonts(doc: SwDoc): Set<string> {
|
||||
const fonts = new Set<string>();
|
||||
for (const s of PARA_STYLE_POOL) if (s.char.fontName) fonts.add(s.char.fontName);
|
||||
for (const n of doc.nodes) for (const r of n.runs) if (r.attrs.fontName) fonts.add(r.attrs.fontName);
|
||||
return fonts;
|
||||
}
|
||||
|
||||
function exportParts(doc: SwDoc): OdfParts {
|
||||
const fontDecls = [...collectFonts(doc)]
|
||||
.map((f) => `<style:font-face style:name="${esc(f)}" svg:font-family="${esc(`'${f}'`)}"/>`)
|
||||
.join("");
|
||||
|
||||
const styles =
|
||||
`<style:default-style style:family="paragraph"><style:text-properties fo:language="en" fo:country="US"/></style:default-style>` +
|
||||
PARA_STYLE_POOL.map((s) => {
|
||||
const a = attrs({
|
||||
"style:name": s.name,
|
||||
"style:display-name": s.uiName !== s.name ? s.uiName : undefined,
|
||||
"style:family": "paragraph",
|
||||
"style:parent-style-name": s.parent,
|
||||
"style:next-style-name": s.next,
|
||||
"style:default-outline-level": s.outlineLevel ? String(s.outlineLevel) : undefined,
|
||||
});
|
||||
return `<style:style${a}>${paraProps(s.para, false)}${textProps(s.char)}</style:style>`;
|
||||
}).join("");
|
||||
|
||||
const pd = doc.pageDesc;
|
||||
const stylesAutomatic =
|
||||
`<style:page-layout style:name="pm1"><style:page-layout-properties` +
|
||||
attrs({
|
||||
"fo:page-width": `${num(pd.width)}cm`,
|
||||
"fo:page-height": `${num(pd.height)}cm`,
|
||||
"style:print-orientation": pd.width > pd.height ? "landscape" : "portrait",
|
||||
"fo:margin-top": `${num(pd.marginTop)}cm`,
|
||||
"fo:margin-bottom": `${num(pd.marginBottom)}cm`,
|
||||
"fo:margin-left": `${num(pd.marginLeft)}cm`,
|
||||
"fo:margin-right": `${num(pd.marginRight)}cm`,
|
||||
}) +
|
||||
`/></style:page-layout>`;
|
||||
const masterStyles = `<style:master-page style:name="Standard" style:page-layout-name="pm1"/>`;
|
||||
|
||||
const pStyles = new AutoStylePool("P");
|
||||
const tStyles = new AutoStylePool("T");
|
||||
const listStyles: Partial<Record<SwListKind, string>> = {};
|
||||
const listStyleXmls: string[] = [];
|
||||
const listStyleName = (kind: SwListKind) =>
|
||||
(listStyles[kind] ??= (() => {
|
||||
const name = `L${listStyleXmls.length + 1}`;
|
||||
listStyleXmls.push(listStyleXml(name, kind));
|
||||
return name;
|
||||
})());
|
||||
|
||||
const paraXml = (node: SwTextNode): string => {
|
||||
const { list: _l, listLevel: _ll, ...direct } = node.attrs;
|
||||
const empty = getNodeText(node) === "";
|
||||
const emptyAttrs = empty ? node.runs[0]?.attrs ?? {} : {};
|
||||
const pProps = paraProps(direct, true) + textProps(emptyAttrs);
|
||||
const styleName = pProps
|
||||
? pStyles.get(`${node.style}|${pProps}`, (name) =>
|
||||
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${esc(node.style)}">${pProps}</style:style>`,
|
||||
)
|
||||
: node.style;
|
||||
const tw = new TextWriter();
|
||||
let inner = "";
|
||||
for (const run of node.runs) {
|
||||
if (!run.text) continue;
|
||||
const tProps = textProps(run.attrs);
|
||||
tw.out = "";
|
||||
tw.write(run.text);
|
||||
if (tProps) {
|
||||
const t = tStyles.get(tProps, (name) =>
|
||||
`<style:style style:name="${name}" style:family="text">${tProps}</style:style>`,
|
||||
);
|
||||
inner += `<text:span text:style-name="${t}">${tw.out}</text:span>`;
|
||||
} else {
|
||||
inner += tw.out;
|
||||
}
|
||||
}
|
||||
const level = getParaStyle(node.style).outlineLevel;
|
||||
return level
|
||||
? `<text:h text:style-name="${esc(styleName)}" text:outline-level="${level}">${inner}</text:h>`
|
||||
: `<text:p text:style-name="${esc(styleName)}">${inner}</text:p>`;
|
||||
};
|
||||
|
||||
const blockXml = (block: SwBlock, top: boolean): string => {
|
||||
if (block.kind === "para") return paraXml(block.node);
|
||||
const styleAttr = top ? ` text:style-name="${listStyleName(block.list)}"` : "";
|
||||
return (
|
||||
`<text:list${styleAttr}>` +
|
||||
block.items
|
||||
.map((item) => `<text:list-item>${item.map((b) => blockXml(b, false)).join("")}</text:list-item>`)
|
||||
.join("") +
|
||||
`</text:list>`
|
||||
);
|
||||
};
|
||||
|
||||
const body = buildBlocks(doc.nodes).map((b) => blockXml(b, true)).join("");
|
||||
return {
|
||||
fontDecls,
|
||||
styles,
|
||||
stylesAutomatic,
|
||||
masterStyles,
|
||||
contentAutomatic: pStyles.xml.join("") + tStyles.xml.join("") + listStyleXmls.join(""),
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
const XML_DECL = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
||||
|
||||
/** Flat XML ODF Text Document (.fodt). */
|
||||
export function exportFlatOdt(doc: SwDoc): string {
|
||||
const p = exportParts(doc);
|
||||
return (
|
||||
XML_DECL +
|
||||
`<office:document ${NS_DECLS} office:version="1.3" office:mimetype="${ODT_MIMETYPE}">` +
|
||||
`<office:meta><meta:generator>${GENERATOR}</meta:generator></office:meta>` +
|
||||
`<office:font-face-decls>${p.fontDecls}</office:font-face-decls>` +
|
||||
`<office:styles>${p.styles}</office:styles>` +
|
||||
`<office:automatic-styles>${p.stylesAutomatic}${p.contentAutomatic}</office:automatic-styles>` +
|
||||
`<office:master-styles>${p.masterStyles}</office:master-styles>` +
|
||||
`<office:body><office:text>${p.body}</office:text></office:body>` +
|
||||
`</office:document>`
|
||||
);
|
||||
}
|
||||
|
||||
/** ODF Text Document package (.odt). */
|
||||
export function exportOdt(doc: SwDoc): Uint8Array {
|
||||
const p = exportParts(doc);
|
||||
const content =
|
||||
XML_DECL +
|
||||
`<office:document-content ${NS_DECLS} office:version="1.3">` +
|
||||
`<office:font-face-decls>${p.fontDecls}</office:font-face-decls>` +
|
||||
`<office:automatic-styles>${p.contentAutomatic}</office:automatic-styles>` +
|
||||
`<office:body><office:text>${p.body}</office:text></office:body>` +
|
||||
`</office:document-content>`;
|
||||
const styles =
|
||||
XML_DECL +
|
||||
`<office:document-styles ${NS_DECLS} office:version="1.3">` +
|
||||
`<office:font-face-decls>${p.fontDecls}</office:font-face-decls>` +
|
||||
`<office:styles>${p.styles}</office:styles>` +
|
||||
`<office:automatic-styles>${p.stylesAutomatic}</office:automatic-styles>` +
|
||||
`<office:master-styles>${p.masterStyles}</office:master-styles>` +
|
||||
`</office:document-styles>`;
|
||||
const meta =
|
||||
XML_DECL +
|
||||
`<office:document-meta ${NS_DECLS} office:version="1.3"><office:meta><meta:generator>${GENERATOR}</meta:generator></office:meta></office:document-meta>`;
|
||||
const manifest =
|
||||
XML_DECL +
|
||||
`<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3">` +
|
||||
`<manifest:file-entry manifest:full-path="/" manifest:version="1.3" manifest:media-type="${ODT_MIMETYPE}"/>` +
|
||||
["content.xml", "styles.xml", "meta.xml"]
|
||||
.map((f) => `<manifest:file-entry manifest:full-path="${f}" manifest:media-type="text/xml"/>`)
|
||||
.join("") +
|
||||
`</manifest:manifest>`;
|
||||
|
||||
// The mimetype entry must come first and be stored uncompressed.
|
||||
const files: Zippable = {
|
||||
mimetype: [strToU8(ODT_MIMETYPE), { level: 0 }],
|
||||
"content.xml": strToU8(content),
|
||||
"styles.xml": strToU8(styles),
|
||||
"meta.xml": strToU8(meta),
|
||||
"META-INF/manifest.xml": strToU8(manifest),
|
||||
};
|
||||
return zipSync(files, { level: 6 });
|
||||
}
|
||||
18
desktop/src/sw/source/filter/xml/xmlnmspe.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/** ODF XML namespaces (include/xmloff/xmlnamespace.hxx). */
|
||||
export const NS = {
|
||||
office: "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
|
||||
style: "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
|
||||
text: "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
|
||||
table: "urn:oasis:names:tc:opendocument:xmlns:table:1.0",
|
||||
fo: "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
|
||||
svg: "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0",
|
||||
meta: "urn:oasis:names:tc:opendocument:xmlns:meta:1.0",
|
||||
dc: "http://purl.org/dc/elements/1.1/",
|
||||
manifest: "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0",
|
||||
} as const;
|
||||
|
||||
export const ODT_MIMETYPE = "application/vnd.oasis.opendocument.text";
|
||||
|
||||
export const NS_DECLS = (["office", "style", "text", "table", "fo", "svg", "meta", "dc"] as const)
|
||||
.map((p) => `xmlns:${p}="${NS[p]}"`)
|
||||
.join(" ");
|
||||
63
desktop/src/sw/source/ui/dialog/wordcountdialog.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Tools > Word Count — counterpart of sw/source/ui/dialog/wordcountdialog.cxx.
|
||||
* Modeless and live-updating, like upstream.
|
||||
*/
|
||||
import { Dialog } from "../../../../vcl/source/window/dialog";
|
||||
import type { SwDocStat } from "../../core/doc/DocumentStatisticsManager";
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString();
|
||||
|
||||
export function SwWordCountDialog({
|
||||
doc,
|
||||
selection,
|
||||
pages,
|
||||
onClose,
|
||||
}: {
|
||||
doc: SwDocStat;
|
||||
selection: SwDocStat | null;
|
||||
pages: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const rows: [string, (s: SwDocStat) => number][] = [
|
||||
["Words", (s) => s.words],
|
||||
["Characters including spaces", (s) => s.chars],
|
||||
["Characters excluding spaces", (s) => s.charsExclSpaces],
|
||||
["Paragraphs", (s) => s.paragraphs],
|
||||
];
|
||||
return (
|
||||
<Dialog
|
||||
title="Word and Character Count"
|
||||
width={420}
|
||||
onClose={onClose}
|
||||
buttons={
|
||||
<button className="vcl-button" data-default="" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<table className="sw-wordcount">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>Selection</th>
|
||||
<th>Document</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([label, get]) => (
|
||||
<tr key={label}>
|
||||
<th>{label}</th>
|
||||
<td>{selection ? fmt(get(selection)) : 0}</td>
|
||||
<td>{fmt(get(doc))}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<th>Pages</th>
|
||||
<td />
|
||||
<td>{fmt(pages)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
59
desktop/src/sw/source/uibase/app/docsh.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* SwDocShell — loads and stores documents through the filters
|
||||
* (sw/source/uibase/app/docsh.cxx: ConvertFrom / ConvertTo). The UI flow
|
||||
* around it (dialogs, "save changes?") is in the view's object shell slots.
|
||||
*/
|
||||
import { strFromU8, strToU8 } from "fflate";
|
||||
import {
|
||||
filterForPath,
|
||||
WRITER_FILTERS,
|
||||
type SfxFilter,
|
||||
} from "../../../../filter/source/config/fragments/filters";
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
import { importText } from "../../filter/ascii/parasc";
|
||||
import { exportText } from "../../filter/ascii/wrtasc";
|
||||
import { exportHtml } from "../../filter/html/wrthtml";
|
||||
import { importFlatOdt, importOdt } from "../../filter/xml/swxml";
|
||||
import { exportFlatOdt, exportOdt } from "../../filter/xml/wrtxml";
|
||||
|
||||
const byName = (name: string) => WRITER_FILTERS.find((f) => f.name === name)!;
|
||||
|
||||
/** Type detection (like filter/source/config type detection): extension, then content sniffing. */
|
||||
export function detectFilter(path: string, bytes: Uint8Array): SfxFilter {
|
||||
if (bytes[0] === 0x50 && bytes[1] === 0x4b) return byName("writer8"); // "PK" zip
|
||||
const byExt = filterForPath(path);
|
||||
if (byExt?.import) return byExt;
|
||||
const head = strFromU8(bytes.subarray(0, 512));
|
||||
if (head.includes("<office:document")) return byName("OpenDocument Text Flat XML");
|
||||
return byName("Text");
|
||||
}
|
||||
|
||||
export function convertFrom(path: string, bytes: Uint8Array): { doc: SwDoc; filter: SfxFilter } {
|
||||
const filter = detectFilter(path, bytes);
|
||||
switch (filter.name) {
|
||||
case "writer8":
|
||||
return { doc: importOdt(bytes), filter };
|
||||
case "OpenDocument Text Flat XML":
|
||||
return { doc: importFlatOdt(strFromU8(bytes)), filter };
|
||||
default:
|
||||
return { doc: importText(strFromU8(bytes)), filter };
|
||||
}
|
||||
}
|
||||
|
||||
export function convertTo(doc: SwDoc, filter: SfxFilter, title: string): Uint8Array {
|
||||
switch (filter.name) {
|
||||
case "writer8":
|
||||
return exportOdt(doc);
|
||||
case "OpenDocument Text Flat XML":
|
||||
return strToU8(exportFlatOdt(doc));
|
||||
case "HTML (StarWriter)":
|
||||
return strToU8(exportHtml(doc, title));
|
||||
default:
|
||||
return strToU8(exportText(doc));
|
||||
}
|
||||
}
|
||||
|
||||
/** True if saving in this format loses formatting (the "Keep current format" warning). */
|
||||
export function isAlienFormat(filter: SfxFilter): boolean {
|
||||
return filter.name !== "writer8" && filter.name !== "OpenDocument Text Flat XML";
|
||||
}
|
||||
177
desktop/src/sw/source/uibase/app/docsh2.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* SwDocShell document lifecycle — New/Open/Save/Save As/Export and the
|
||||
* "Save changes?" query (sw/source/uibase/app/docsh2.cxx together with
|
||||
* sfx2's SfxObjectShell::ExecFile_Impl / PrepareClose in objserv.cxx).
|
||||
*/
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
filterForPath,
|
||||
WRITER_FILTERS,
|
||||
type SfxFilter,
|
||||
} from "../../../../filter/source/config/fragments/filters";
|
||||
import type { SfxShell } from "../../../../sfx2/source/control/dispatch";
|
||||
import {
|
||||
baseName,
|
||||
pickAndRead,
|
||||
pickSavePath,
|
||||
readFile,
|
||||
writeFile,
|
||||
} from "../../../../sfx2/source/doc/docfile";
|
||||
import { createDoc } from "../../core/doc/docnew";
|
||||
import type { SwWrtShell } from "../wrtsh/wrtsh";
|
||||
import { convertFrom, convertTo, isAlienFormat } from "./docsh";
|
||||
|
||||
export interface SwDocInfo {
|
||||
readonly path: string | null;
|
||||
readonly title: string;
|
||||
readonly filter: SfxFilter;
|
||||
}
|
||||
|
||||
/** UI callbacks the shell needs (weld::MessageDialog upstream). */
|
||||
export interface SfxInteraction {
|
||||
query(
|
||||
message: string,
|
||||
secondary: string,
|
||||
buttons: { id: string; label: string; default?: boolean }[],
|
||||
): Promise<string>;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
let untitledCount = 0;
|
||||
const untitled = () => `Untitled ${++untitledCount}`;
|
||||
const stripExt = (name: string) => name.replace(/\.[^.]+$/, "");
|
||||
|
||||
export class SwDocShell {
|
||||
private info: SwDocInfo = { path: null, title: untitled(), filter: DEFAULT_FILTER };
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
private readonly wrtShell: SwWrtShell,
|
||||
private readonly ui: SfxInteraction,
|
||||
) {}
|
||||
|
||||
subscribe = (l: () => void) => {
|
||||
this.listeners.add(l);
|
||||
return () => this.listeners.delete(l);
|
||||
};
|
||||
|
||||
getInfo = (): SwDocInfo => this.info;
|
||||
|
||||
private setInfo(info: SwDocInfo): void {
|
||||
this.info = info;
|
||||
for (const l of this.listeners) l();
|
||||
}
|
||||
|
||||
/** SfxObjectShell::PrepareClose — true if it is fine to discard the document. */
|
||||
async prepareClose(): Promise<boolean> {
|
||||
if (!this.wrtShell.getSnapshot().modified) return true;
|
||||
const answer = await this.ui.query(
|
||||
`Save changes to document "${this.info.title}" before closing?`,
|
||||
"Your changes will be lost if you don't save them.",
|
||||
[
|
||||
{ id: "discard", label: "Don't Save" },
|
||||
{ id: "cancel", label: "Cancel" },
|
||||
{ id: "save", label: "Save", default: true },
|
||||
],
|
||||
);
|
||||
if (answer === "save") return this.save();
|
||||
return answer === "discard";
|
||||
}
|
||||
|
||||
async newDoc(): Promise<void> {
|
||||
if (!(await this.prepareClose())) return;
|
||||
this.wrtShell.load(createDoc());
|
||||
this.setInfo({ path: null, title: untitled(), filter: DEFAULT_FILTER });
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
if (!(await this.prepareClose())) return;
|
||||
const file = await pickAndRead(WRITER_FILTERS.filter((f) => f.import));
|
||||
if (file) this.load(file.path, file.bytes);
|
||||
}
|
||||
|
||||
async openPath(path: string): Promise<void> {
|
||||
try {
|
||||
this.load(path, await readFile(path));
|
||||
} catch (e) {
|
||||
this.ui.error(`Could not read "${baseName(path)}": ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
private load(path: string, bytes: Uint8Array): void {
|
||||
try {
|
||||
const { doc, filter } = convertFrom(path, bytes);
|
||||
this.wrtShell.load(doc);
|
||||
this.setInfo({ path, title: baseName(path), filter });
|
||||
} catch (e) {
|
||||
this.ui.error(`General input/output error while loading "${baseName(path)}".\n${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** File > Save. Returns false if the document was not saved. */
|
||||
async save(): Promise<boolean> {
|
||||
const { path, filter } = this.info;
|
||||
if (!path || !filter.export) return this.saveAs();
|
||||
if (isAlienFormat(filter)) {
|
||||
const answer = await this.ui.query(
|
||||
`Use ${filter.uiName} Format?`,
|
||||
"This document may contain formatting or content that cannot be saved in the currently selected file format.",
|
||||
[
|
||||
{ id: "keep", label: `Use ${filter.uiName} Format!` },
|
||||
{ id: "odf", label: "Use ODF Format!", default: true },
|
||||
],
|
||||
);
|
||||
if (answer === "odf") return this.saveAs(DEFAULT_FILTER);
|
||||
}
|
||||
return this.store(path, filter, true);
|
||||
}
|
||||
|
||||
/** File > Save As (or Export when `keepIdentity` is false). */
|
||||
async saveAs(preferred?: SfxFilter, keepIdentity = true): Promise<boolean> {
|
||||
const filter = preferred ?? (this.info.filter.export ? this.info.filter : DEFAULT_FILTER);
|
||||
const name = `${stripExt(this.info.title)}.${filter.extensions[0]}`;
|
||||
const exportable = WRITER_FILTERS.filter((f) => f.export);
|
||||
const choices = preferred ? [preferred] : [filter, ...exportable.filter((f) => f !== filter)];
|
||||
const path = await pickSavePath(name, choices);
|
||||
if (!path) return false;
|
||||
const chosen = filterForPath(path) ?? filter;
|
||||
return this.store(path, chosen.export ? chosen : filter, keepIdentity);
|
||||
}
|
||||
|
||||
async exportAs(filterName: string): Promise<boolean> {
|
||||
const filter = WRITER_FILTERS.find((f) => f.name === filterName);
|
||||
return filter ? this.saveAs(filter, false) : false;
|
||||
}
|
||||
|
||||
private async store(path: string, filter: SfxFilter, keepIdentity: boolean): Promise<boolean> {
|
||||
try {
|
||||
const bytes = convertTo(this.wrtShell.doc, filter, stripExt(this.info.title));
|
||||
await writeFile(path, bytes, filter.mimeType);
|
||||
} catch (e) {
|
||||
this.ui.error(`Error saving the document "${baseName(path)}":\n${e}`);
|
||||
return false;
|
||||
}
|
||||
if (keepIdentity) {
|
||||
this.wrtShell.setModified(false);
|
||||
this.setInfo({ path, title: baseName(path), filter });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Slots of the document shell (SID_SAVEDOC, SID_SAVEASDOC, …). */
|
||||
export function createDocShellSlots(docShell: SwDocShell, wrtShell: SwWrtShell): SfxShell {
|
||||
return {
|
||||
name: "SwDocShell",
|
||||
slots: {
|
||||
".uno:Save": {
|
||||
exec: () => docShell.save(),
|
||||
state: () => ({ enabled: wrtShell.getSnapshot().modified || !docShell.getInfo().path }),
|
||||
},
|
||||
".uno:SaveAs": { exec: () => docShell.saveAs() },
|
||||
".uno:ExportToHTML": { exec: () => docShell.exportAs("HTML (StarWriter)") },
|
||||
".uno:ExportToText": { exec: () => docShell.exportAs("Text") },
|
||||
".uno:CloseDoc": { exec: () => docShell.newDoc() },
|
||||
},
|
||||
};
|
||||
}
|
||||
57
desktop/src/sw/source/uibase/dochdl/swdtflvr.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* SwTransferable — clipboard data for Writer (sw/source/uibase/dochdl/swdtflvr.cxx).
|
||||
* Offers text/plain and text/html to other applications and keeps the
|
||||
* copied fragment itself, so copy & paste inside the app is lossless.
|
||||
*/
|
||||
import { getNodeText, type SwTextNode } from "../../../inc/ndtxt";
|
||||
import { exportHtmlFragment } from "../../filter/html/wrthtml";
|
||||
|
||||
let internal: { text: string; fragment: SwTextNode[] } | null = null;
|
||||
|
||||
export interface SwClipboardContent {
|
||||
text: string;
|
||||
/** Present when the clipboard holds a fragment copied from this app. */
|
||||
fragment?: SwTextNode[];
|
||||
}
|
||||
|
||||
function remember(fragment: SwTextNode[]): { text: string; html: string } {
|
||||
const text = fragment.map(getNodeText).join("\n");
|
||||
internal = { text, fragment };
|
||||
return { text, html: exportHtmlFragment(fragment) };
|
||||
}
|
||||
|
||||
function recall(text: string): SwClipboardContent {
|
||||
const norm = (s: string) => s.replace(/\r\n?/g, "\n");
|
||||
return internal && norm(internal.text) === norm(text) ? { text, fragment: internal.fragment } : { text };
|
||||
}
|
||||
|
||||
/** Fills a copy/cut event's DataTransfer. */
|
||||
export function writeTransferable(dt: DataTransfer, fragment: SwTextNode[]): void {
|
||||
const { text, html } = remember(fragment);
|
||||
dt.setData("text/plain", text);
|
||||
dt.setData("text/html", html);
|
||||
}
|
||||
|
||||
export function readTransferable(dt: DataTransfer): SwClipboardContent {
|
||||
return recall(dt.getData("text/plain"));
|
||||
}
|
||||
|
||||
/** Copy via the async Clipboard API (menu / toolbar Copy). */
|
||||
export async function copyToSystemClipboard(fragment: SwTextNode[]): Promise<void> {
|
||||
const { text, html } = remember(fragment);
|
||||
if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/plain": new Blob([text], { type: "text/plain" }),
|
||||
"text/html": new Blob([html], { type: "text/html" }),
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
|
||||
/** Paste via the async Clipboard API (menu / toolbar Paste). */
|
||||
export async function readSystemClipboard(): Promise<SwClipboardContent> {
|
||||
return recall(await navigator.clipboard.readText());
|
||||
}
|
||||
140
desktop/src/sw/source/uibase/docvw/edtwin.css
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/* Document window: scroll area, pages and text frames. */
|
||||
|
||||
.sw-scrollwin {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: 16px 16px 0;
|
||||
background: var(--lo-app-bg);
|
||||
}
|
||||
|
||||
.sw-sizer {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sw-pages {
|
||||
transform-origin: 0 0;
|
||||
padding-bottom: 1px;
|
||||
outline: none;
|
||||
color: #000000;
|
||||
caret-color: #000000;
|
||||
cursor: text;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.sw-page {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.sw-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sw-para {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sw-txt {
|
||||
position: relative;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
tab-size: 1.25cm;
|
||||
font-kerning: normal;
|
||||
}
|
||||
|
||||
/* List label (SwTextFormatter::NewNumberPortion). */
|
||||
.sw-txt[data-label]::before {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
left: -0.635cm;
|
||||
width: 0.5cm;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Formatting marks (View > Formatting Marks). */
|
||||
.sw-show-marks .sw-txt > span:last-of-type::after,
|
||||
.sw-show-marks .sw-txt[data-empty]::after {
|
||||
content: "¶";
|
||||
color: #729fcf;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
text-decoration: none;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.sw-show-marks .sw-txt[data-empty]::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.sw-show-marks .sw-page {
|
||||
outline: 1px dashed transparent;
|
||||
}
|
||||
|
||||
.sw-show-marks .sw-body {
|
||||
outline: 1px solid #c0c0c0;
|
||||
}
|
||||
|
||||
::highlight(sw-inactive-selection) {
|
||||
background-color: rgba(114, 159, 207, 0.35);
|
||||
}
|
||||
|
||||
.sw-pages ::selection {
|
||||
background: rgba(114, 159, 207, 0.55);
|
||||
}
|
||||
|
||||
@media print {
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body,
|
||||
#root {
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.lo-app > :not(.lo-workarea),
|
||||
.lo-workarea > :not(.lo-main),
|
||||
.lo-main > :not(.sw-scrollwin),
|
||||
.vcl-dialog,
|
||||
.vcl-dialog-overlay {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.lo-app,
|
||||
.lo-workarea,
|
||||
.lo-main,
|
||||
.sw-scrollwin {
|
||||
display: block !important;
|
||||
overflow: visible !important;
|
||||
padding: 0 !important;
|
||||
height: auto !important;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.sw-sizer {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.sw-pages {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.sw-page {
|
||||
box-shadow: none;
|
||||
margin: 0 !important;
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
.sw-show-marks .sw-body {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
409
desktop/src/sw/source/uibase/docvw/edtwin.tsx
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
/**
|
||||
* SwEditWin — the document window (sw/source/uibase/docvw/edtwin.cxx).
|
||||
* Upstream it receives raw key/mouse events and turns them into SwWrtShell
|
||||
* calls. Here the page area is a contentEditable host whose DOM is fully
|
||||
* owned by React: every native edit is cancelled in `beforeinput` and
|
||||
* replayed on the model, then re-rendered. The browser still provides caret
|
||||
* movement, selection, IME and spell checking.
|
||||
*
|
||||
* The page layout (SwRootFrame → SwPageFrame → SwTextFrame) is rendered here
|
||||
* too; paragraph heights are measured after each render to paginate.
|
||||
*/
|
||||
import {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
type KeyboardEvent,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import {
|
||||
ACCELERATORS,
|
||||
keyEventToAccel,
|
||||
} from "../../../../officecfg/registry/data/org/openoffice/Office/Accelerators";
|
||||
import { CM_TO_PX } from "../../../inc/doc";
|
||||
import { hasSelection, pamEquals, type SwPaM } from "../../../inc/pam";
|
||||
import { computeListLabels } from "../../core/doc/number";
|
||||
import { bodyHeightPx, calcPageStarts, sameStarts } from "../../core/layout/layact";
|
||||
import {
|
||||
caretRect,
|
||||
domToPos,
|
||||
pamToRange,
|
||||
readDomSelection,
|
||||
writeDomSelection,
|
||||
} from "../../core/layout/trvlfrm";
|
||||
import { SwTextFrame } from "../../core/text/txtfrm";
|
||||
import {
|
||||
copyToSystemClipboard,
|
||||
readSystemClipboard,
|
||||
readTransferable,
|
||||
writeTransferable,
|
||||
} from "../dochdl/swdtflvr";
|
||||
import type { SwShellSnapshot, SwWrtShell } from "../wrtsh/wrtsh";
|
||||
import "./edtwin.css";
|
||||
|
||||
export interface SwEditWinHandle {
|
||||
grabFocus(): void;
|
||||
/** Scrolls the cursor into view (after the next render). */
|
||||
showCursor(): void;
|
||||
copy(cut: boolean): Promise<void>;
|
||||
paste(unformatted: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
interface SwEditWinProps {
|
||||
shell: SwWrtShell;
|
||||
snapshot: SwShellSnapshot;
|
||||
/** Zoom in percent. */
|
||||
zoom: number;
|
||||
showMarks: boolean;
|
||||
pageStarts: readonly number[];
|
||||
onLayout: (pageStarts: number[]) => void;
|
||||
/** Executes an accelerator's command; returns false if unhandled. */
|
||||
onCommand: (command: string) => boolean;
|
||||
onContextMenu: (x: number, y: number) => void;
|
||||
ref?: Ref<SwEditWinHandle>;
|
||||
}
|
||||
|
||||
const PAGE_GAP_PX = 16;
|
||||
const INACTIVE_SELECTION = "sw-inactive-selection";
|
||||
|
||||
export function SwEditWin({
|
||||
shell,
|
||||
snapshot,
|
||||
zoom,
|
||||
showMarks,
|
||||
pageStarts,
|
||||
onLayout,
|
||||
onCommand,
|
||||
onContextMenu,
|
||||
ref,
|
||||
}: SwEditWinProps) {
|
||||
const { doc, pam } = snapshot;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const composing = useRef<SwPaM | null>(null);
|
||||
const lastPam = useRef(pam);
|
||||
const latest = useRef({ shell, onCommand });
|
||||
latest.current = { shell, onCommand };
|
||||
const [epoch, setEpoch] = useState(0);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [contentHeight, setContentHeight] = useState(0);
|
||||
const [fontsLoaded, setFontsLoaded] = useState(0);
|
||||
|
||||
const labels = useMemo(() => computeListLabels(doc), [doc]);
|
||||
const scale = zoom / 100;
|
||||
const pd = doc.pageDesc;
|
||||
|
||||
// Page starts may lag one render behind the document; keep them valid.
|
||||
const starts = pageStarts.filter((s, i) => s < doc.nodes.length && (i === 0 ? s === 0 : s > pageStarts[i - 1]));
|
||||
if (starts[0] !== 0) starts.unshift(0);
|
||||
|
||||
const isFocused = () => document.activeElement === rootRef.current;
|
||||
|
||||
/** Scrolls the scroll window so the cursor is visible (SwView::CalcVisArea). */
|
||||
const ensureCursorVisible = () => {
|
||||
const root = rootRef.current;
|
||||
const sc = scrollRef.current;
|
||||
if (!root || !sc) return;
|
||||
const rect = caretRect(root, latest.current.shell.pam.point);
|
||||
if (!rect) return;
|
||||
const view = sc.getBoundingClientRect();
|
||||
if (rect.top < view.top + 8 || rect.bottom > view.bottom - 8) {
|
||||
sc.scrollTop += rect.top - view.top - view.height / 3;
|
||||
}
|
||||
};
|
||||
|
||||
const syncSelectionFromDom = () => {
|
||||
const root = rootRef.current;
|
||||
const domPam = root && readDomSelection(root);
|
||||
if (domPam) latest.current.shell.setSelection(domPam);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
grabFocus() {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
root.focus({ preventScroll: true });
|
||||
writeDomSelection(root, latest.current.shell.pam);
|
||||
},
|
||||
showCursor() {
|
||||
requestAnimationFrame(ensureCursorVisible);
|
||||
},
|
||||
async copy(cut) {
|
||||
const sh = latest.current.shell;
|
||||
if (!hasSelection(sh.pam)) return;
|
||||
await copyToSystemClipboard(sh.copySelection());
|
||||
if (cut) sh.deleteRange(sh.pam);
|
||||
},
|
||||
async paste(unformatted) {
|
||||
const content = await readSystemClipboard();
|
||||
const sh = latest.current.shell;
|
||||
if (content.fragment && !unformatted) sh.insertFragment(content.fragment);
|
||||
else if (content.text) sh.insertPlainText(content.text);
|
||||
},
|
||||
}));
|
||||
|
||||
// After every render: paginate from measured heights, then put the DOM
|
||||
// selection where the model says it is.
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const heights: number[] = [];
|
||||
root.querySelectorAll<HTMLElement>(".sw-para").forEach((el) => {
|
||||
heights[Number(el.dataset.swNode)] = el.offsetHeight;
|
||||
});
|
||||
const measured = calcPageStarts(doc, heights);
|
||||
if (!sameStarts(measured, pageStarts)) onLayout(measured);
|
||||
if (root.offsetHeight !== contentHeight) setContentHeight(root.offsetHeight);
|
||||
|
||||
if (!composing.current && isFocused()) {
|
||||
const domPam = readDomSelection(root);
|
||||
if (!domPam || !pamEquals(domPam, pam)) writeDomSelection(root, pam);
|
||||
if (lastPam.current !== pam) ensureCursorVisible();
|
||||
}
|
||||
lastPam.current = pam;
|
||||
});
|
||||
|
||||
// Re-measure once web fonts have loaded (line heights change).
|
||||
useEffect(() => {
|
||||
document.fonts?.ready.then(() => setFontsLoaded((n) => n + 1));
|
||||
}, []);
|
||||
void fontsLoaded;
|
||||
|
||||
// Show the selection while focus is elsewhere (toolbar boxes, dialogs).
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (typeof CSS === "undefined" || !("highlights" in CSS) || !root) return;
|
||||
const range = !focused && hasSelection(pam) ? pamToRange(root, pam) : null;
|
||||
if (range) CSS.highlights.set(INACTIVE_SELECTION, new Highlight(range));
|
||||
else CSS.highlights.delete(INACTIVE_SELECTION);
|
||||
}, [focused, pam, doc, epoch]);
|
||||
|
||||
// DOM selection → model.
|
||||
useEffect(() => {
|
||||
const onSelectionChange = () => {
|
||||
if (composing.current || !isFocused()) return;
|
||||
syncSelectionFromDom();
|
||||
};
|
||||
document.addEventListener("selectionchange", onSelectionChange);
|
||||
return () => document.removeEventListener("selectionchange", onSelectionChange);
|
||||
}, []);
|
||||
|
||||
// Native editing events (React's onBeforeInput is not the InputEvent API).
|
||||
useEffect(() => {
|
||||
const root = rootRef.current!;
|
||||
|
||||
const targetPam = (e: InputEvent): SwPaM | null => {
|
||||
const r = e.getTargetRanges()[0];
|
||||
if (!r) return null;
|
||||
const mark = domToPos(root, r.startContainer, r.startOffset);
|
||||
const point = domToPos(root, r.endContainer, r.endOffset);
|
||||
return mark && point ? { mark, point } : null;
|
||||
};
|
||||
|
||||
const onBeforeInput = (e: InputEvent) => {
|
||||
if (
|
||||
e.inputType === "insertCompositionText" ||
|
||||
e.inputType === "deleteCompositionText" ||
|
||||
e.inputType === "insertFromComposition"
|
||||
) {
|
||||
return; // IME: let the browser draw; committed in compositionend.
|
||||
}
|
||||
e.preventDefault();
|
||||
const sh = latest.current.shell;
|
||||
syncSelectionFromDom();
|
||||
switch (e.inputType) {
|
||||
case "insertText":
|
||||
if (e.data) sh.insert(e.data);
|
||||
break;
|
||||
case "insertReplacementText": {
|
||||
const text = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
|
||||
sh.replaceRange(targetPam(e) ?? sh.pam, text, "Autocorrect");
|
||||
break;
|
||||
}
|
||||
case "insertParagraph":
|
||||
sh.splitNode();
|
||||
break;
|
||||
case "insertLineBreak":
|
||||
sh.insertLineBreak();
|
||||
break;
|
||||
case "deleteContentBackward":
|
||||
sh.delLeft();
|
||||
break;
|
||||
case "deleteContentForward":
|
||||
sh.delRight();
|
||||
break;
|
||||
case "deleteWordBackward":
|
||||
case "deleteWordForward":
|
||||
case "deleteSoftLineBackward":
|
||||
case "deleteSoftLineForward":
|
||||
case "deleteHardLineBackward":
|
||||
case "deleteHardLineForward":
|
||||
case "deleteEntireSoftLine":
|
||||
case "deleteContent": {
|
||||
const r = targetPam(e);
|
||||
if (hasSelection(sh.pam)) sh.deleteRange(sh.pam);
|
||||
else if (r && hasSelection(r)) sh.deleteRange(r);
|
||||
else if (e.inputType.endsWith("Backward")) sh.delLeft();
|
||||
else sh.delRight();
|
||||
break;
|
||||
}
|
||||
case "historyUndo":
|
||||
sh.undo();
|
||||
break;
|
||||
case "historyRedo":
|
||||
sh.redo();
|
||||
break;
|
||||
case "formatBold":
|
||||
sh.toggleCharAttr("bold");
|
||||
break;
|
||||
case "formatItalic":
|
||||
sh.toggleCharAttr("italic");
|
||||
break;
|
||||
case "formatUnderline":
|
||||
sh.toggleCharAttr("underline");
|
||||
break;
|
||||
case "formatStrikeThrough":
|
||||
sh.toggleCharAttr("strikeout");
|
||||
break;
|
||||
default:
|
||||
// insertFromPaste / insertFromDrop are handled by paste/drop.
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onCompositionStart = () => {
|
||||
composing.current = readDomSelection(root) ?? latest.current.shell.pam;
|
||||
};
|
||||
const onCompositionEnd = (e: CompositionEvent) => {
|
||||
const start = composing.current;
|
||||
const data = e.data ?? "";
|
||||
// Let the browser finish its own composition DOM work, then replay the
|
||||
// result on the model and rebuild the pages the IME touched.
|
||||
setTimeout(() => {
|
||||
composing.current = null;
|
||||
const sh = latest.current.shell;
|
||||
if (start && (data || hasSelection(start))) sh.replaceRange(start, data, "Typing", "typing");
|
||||
setEpoch((n) => n + 1);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
root.addEventListener("beforeinput", onBeforeInput);
|
||||
root.addEventListener("compositionstart", onCompositionStart);
|
||||
root.addEventListener("compositionend", onCompositionEnd);
|
||||
return () => {
|
||||
root.removeEventListener("beforeinput", onBeforeInput);
|
||||
root.removeEventListener("compositionstart", onCompositionStart);
|
||||
root.removeEventListener("compositionend", onCompositionEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.nativeEvent.isComposing || composing.current) return;
|
||||
const command = ACCELERATORS[keyEventToAccel(e)];
|
||||
if (command) {
|
||||
syncSelectionFromDom();
|
||||
if (onCommand(command)) e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
syncSelectionFromDom();
|
||||
const sh = latest.current.shell;
|
||||
if (sh.doc.nodes[sh.pam.point.node].attrs.list) sh.changeIndent(e.shiftKey ? -1 : 1);
|
||||
else if (!e.shiftKey) sh.insert("\t");
|
||||
}
|
||||
};
|
||||
|
||||
const onCopy = (e: ClipboardEvent<HTMLDivElement>, cut: boolean) => {
|
||||
e.preventDefault();
|
||||
syncSelectionFromDom();
|
||||
const sh = latest.current.shell;
|
||||
if (!hasSelection(sh.pam)) return;
|
||||
writeTransferable(e.clipboardData, sh.copySelection());
|
||||
if (cut) sh.deleteRange(sh.pam);
|
||||
};
|
||||
|
||||
const onPaste = (e: ClipboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
syncSelectionFromDom();
|
||||
const content = readTransferable(e.clipboardData);
|
||||
const sh = latest.current.shell;
|
||||
if (content.fragment) sh.insertFragment(content.fragment);
|
||||
else if (content.text) sh.insertPlainText(content.text);
|
||||
};
|
||||
|
||||
const pageW = pd.width * CM_TO_PX;
|
||||
const pageH = pd.height * CM_TO_PX;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="sw-scrollwin"
|
||||
onMouseDown={(e) => {
|
||||
// Clicks on the grey background just focus the document.
|
||||
if (e.target === e.currentTarget || (e.target as Element).classList.contains("sw-sizer")) {
|
||||
e.preventDefault();
|
||||
rootRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="sw-sizer"
|
||||
style={{ width: pageW * scale, height: (contentHeight || pageH) * scale }}
|
||||
>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={showMarks ? "sw-pages sw-show-marks" : "sw-pages"}
|
||||
style={{ width: pageW, transform: `scale(${scale})` }}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck
|
||||
role="textbox"
|
||||
aria-multiline
|
||||
aria-label="Document"
|
||||
onKeyDown={onKeyDown}
|
||||
onCopy={(e) => onCopy(e, false)}
|
||||
onCut={(e) => onCopy(e, true)}
|
||||
onPaste={onPaste}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
if (rootRef.current) writeDomSelection(rootRef.current, latest.current.shell.pam);
|
||||
}}
|
||||
onBlur={() => setFocused(false)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
syncSelectionFromDom();
|
||||
onContextMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
>
|
||||
{starts.map((start, p) => {
|
||||
const end = starts[p + 1] ?? doc.nodes.length;
|
||||
return (
|
||||
<div
|
||||
key={`${epoch}:${p}`}
|
||||
className="sw-page"
|
||||
style={{
|
||||
minHeight: pageH,
|
||||
marginBottom: PAGE_GAP_PX,
|
||||
padding: `${pd.marginTop}cm ${pd.marginRight}cm ${pd.marginBottom}cm ${pd.marginLeft}cm`,
|
||||
}}
|
||||
>
|
||||
<div className="sw-body" style={{ minHeight: bodyHeightPx(pd) }}>
|
||||
{doc.nodes.slice(start, end).map((node, j) => (
|
||||
<SwTextFrame key={node.id} node={node} index={start + j} label={labels[start + j]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
desktop/src/sw/source/uibase/shells/textsh.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* SwTextShell — slots available while the cursor is in text
|
||||
* (sw/source/uibase/shells/textsh.cxx, textsh1.cxx, txtattr.cxx,
|
||||
* basesh.cxx). Execute* and GetState* of upstream map to `exec` / `state`.
|
||||
*/
|
||||
import type { SvxAdjust } from "../../../../editeng/source/items/paraitem";
|
||||
import type { SfxArgs, SfxShell, SfxSlot } from "../../../../sfx2/source/control/dispatch";
|
||||
import { cssFontFamily } from "../../../../unotools/source/misc/fontdefs";
|
||||
import { hasSelection } from "../../../inc/pam";
|
||||
import {
|
||||
findParaStyleByUiName,
|
||||
getParaStyle,
|
||||
PARA_STYLE_POOL,
|
||||
resolveParaStyle,
|
||||
} from "../../core/doc/DocumentStylePoolManager";
|
||||
import type { SwWrtShell } from "../wrtsh/wrtsh";
|
||||
|
||||
export interface SwTextShellContext {
|
||||
copy(cut: boolean): Promise<void>;
|
||||
paste(unformatted: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
/** Entries for the paragraph style box / Styles deck, with a preview look. */
|
||||
export const PARA_STYLE_ENTRIES = PARA_STYLE_POOL.filter((s) => s.name !== "Heading").map((s) => {
|
||||
const c = resolveParaStyle(s.name).char;
|
||||
return {
|
||||
uiName: s.uiName,
|
||||
preview: {
|
||||
fontFamily: cssFontFamily(c.fontName),
|
||||
fontSize: `${Math.min(c.fontSize ?? 12, 16)}pt`,
|
||||
fontWeight: c.bold ? 700 : 400,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const STYLE_COMMANDS: Record<string, string> = {
|
||||
".uno:DefaultParaStyle": "Standard",
|
||||
".uno:TitleParaStyle": "Title",
|
||||
".uno:SubtitleParaStyle": "Subtitle",
|
||||
".uno:Heading1ParaStyle": "Heading_20_1",
|
||||
".uno:Heading2ParaStyle": "Heading_20_2",
|
||||
".uno:Heading3ParaStyle": "Heading_20_3",
|
||||
".uno:TextBodyParaStyle": "Text_20_body",
|
||||
".uno:QuoteParaStyle": "Quotations",
|
||||
".uno:PreformattedParaStyle": "Preformatted_20_Text",
|
||||
};
|
||||
|
||||
const ADJUST_COMMANDS: Record<string, SvxAdjust> = {
|
||||
".uno:LeftPara": "left",
|
||||
".uno:CenterPara": "center",
|
||||
".uno:RightPara": "right",
|
||||
".uno:JustifyPara": "block",
|
||||
};
|
||||
|
||||
const LINE_SPACING_COMMANDS: Record<string, number> = {
|
||||
".uno:SpacePara1": 100,
|
||||
".uno:SpacePara115": 115,
|
||||
".uno:SpacePara15": 150,
|
||||
".uno:SpacePara2": 200,
|
||||
};
|
||||
|
||||
const TOGGLES = {
|
||||
".uno:Bold": "bold",
|
||||
".uno:Italic": "italic",
|
||||
".uno:Underline": "underline",
|
||||
".uno:Strikeout": "strikeout",
|
||||
} as const;
|
||||
|
||||
const colorArg = (args?: SfxArgs) => (typeof args?.Color === "string" ? args.Color : undefined);
|
||||
|
||||
export function createTextShell(sh: SwWrtShell, ctx: SwTextShellContext): SfxShell {
|
||||
const slots: Record<string, SfxSlot> = {
|
||||
// Editing (basesh.cxx / textsh1.cxx)
|
||||
".uno:Undo": { exec: () => sh.undo(), state: () => ({ enabled: !!sh.getSnapshot().undoComment }) },
|
||||
".uno:Redo": { exec: () => sh.redo(), state: () => ({ enabled: !!sh.getSnapshot().redoComment }) },
|
||||
".uno:Cut": { exec: () => ctx.copy(true), state: () => ({ enabled: hasSelection(sh.pam) }) },
|
||||
".uno:Copy": { exec: () => ctx.copy(false), state: () => ({ enabled: hasSelection(sh.pam) }) },
|
||||
".uno:Paste": { exec: () => ctx.paste(false) },
|
||||
".uno:PasteUnformatted": { exec: () => ctx.paste(true) },
|
||||
".uno:SelectAll": { exec: () => sh.selectAll() },
|
||||
".uno:InsertPagebreak": { exec: () => sh.insertPageBreak() },
|
||||
".uno:InsertDateField": { exec: () => sh.insert(new Date().toLocaleDateString()) },
|
||||
".uno:InsertTimeField": { exec: () => sh.insert(new Date().toLocaleTimeString()) },
|
||||
|
||||
// Character attributes (txtattr.cxx)
|
||||
".uno:SuperScript": {
|
||||
exec: () => sh.toggleEscapement("super"),
|
||||
state: () => ({ checked: sh.getCharAttrs().escapement === "super" }),
|
||||
},
|
||||
".uno:SubScript": {
|
||||
exec: () => sh.toggleEscapement("sub"),
|
||||
state: () => ({ checked: sh.getCharAttrs().escapement === "sub" }),
|
||||
},
|
||||
".uno:ResetAttributes": { exec: () => sh.resetCharAttrs() },
|
||||
".uno:FontColor": { exec: (a) => sh.setCharAttrs({ color: colorArg(a) }) },
|
||||
".uno:CharBackColor": { exec: (a) => sh.setCharAttrs({ highlight: colorArg(a) }) },
|
||||
".uno:CharFontName": {
|
||||
exec: (a) => typeof a?.CharFontName === "string" && sh.setCharAttrs({ fontName: a.CharFontName }),
|
||||
state: () => ({ value: sh.getCharAttrs().fontName }),
|
||||
},
|
||||
".uno:FontHeight": {
|
||||
exec: (a) => typeof a?.FontHeight === "number" && sh.setCharAttrs({ fontSize: a.FontHeight }),
|
||||
state: () => ({ value: sh.getCharAttrs().fontSize }),
|
||||
},
|
||||
|
||||
// Paragraph attributes and lists
|
||||
".uno:DefaultBullet": {
|
||||
exec: () => sh.toggleList("bullet"),
|
||||
state: () => ({ checked: sh.getParaAttrs().list === "bullet" }),
|
||||
},
|
||||
".uno:DefaultNumbering": {
|
||||
exec: () => sh.toggleList("number"),
|
||||
state: () => ({ checked: sh.getParaAttrs().list === "number" }),
|
||||
},
|
||||
".uno:IncrementIndent": { exec: () => sh.changeIndent(1) },
|
||||
".uno:DecrementIndent": { exec: () => sh.changeIndent(-1) },
|
||||
".uno:ULSpacing": {
|
||||
exec: (a) =>
|
||||
sh.setParaAttrs({ spaceBefore: Number(a?.Upper ?? 0), spaceAfter: Number(a?.Lower ?? 0) }),
|
||||
state: () => {
|
||||
const p = sh.getParaAttrs();
|
||||
return { value: { upper: p.spaceBefore ?? 0, lower: p.spaceAfter ?? 0 } };
|
||||
},
|
||||
},
|
||||
".uno:LeftRightParaMargin": {
|
||||
exec: (a) => sh.setParaAttrs({ leftMargin: Number(a?.Left ?? 0) }),
|
||||
state: () => ({ value: sh.getParaAttrs().leftMargin ?? 0 }),
|
||||
},
|
||||
|
||||
// Paragraph styles (.uno:StyleApply and the Styles menu)
|
||||
".uno:StyleApply": {
|
||||
exec: (a) => {
|
||||
const style = typeof a?.Style === "string" ? findParaStyleByUiName(a.Style) : undefined;
|
||||
if (style) sh.setParaStyle(style.name);
|
||||
},
|
||||
state: () => ({
|
||||
value: { current: getParaStyle(sh.getParaStyle()).uiName, styles: PARA_STYLE_ENTRIES },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
for (const [cmd, key] of Object.entries(TOGGLES)) {
|
||||
slots[cmd] = { exec: () => sh.toggleCharAttr(key), state: () => ({ checked: !!sh.getCharAttrs()[key] }) };
|
||||
}
|
||||
for (const [cmd, adjust] of Object.entries(ADJUST_COMMANDS)) {
|
||||
slots[cmd] = {
|
||||
exec: () => sh.setParaAttrs({ adjust }, "Align"),
|
||||
state: () => ({ checked: (sh.getParaAttrs().adjust ?? "left") === adjust }),
|
||||
};
|
||||
}
|
||||
for (const [cmd, lineHeight] of Object.entries(LINE_SPACING_COMMANDS)) {
|
||||
slots[cmd] = {
|
||||
exec: () => sh.setParaAttrs({ lineHeight }, "Line spacing"),
|
||||
state: () => ({ checked: (sh.getParaAttrs().lineHeight ?? 100) === lineHeight }),
|
||||
};
|
||||
}
|
||||
for (const [cmd, style] of Object.entries(STYLE_COMMANDS)) {
|
||||
slots[cmd] = { exec: () => sh.setParaStyle(style), state: () => ({ checked: sh.getParaStyle() === style }) };
|
||||
}
|
||||
|
||||
return { name: "SwTextShell", slots };
|
||||
}
|
||||
160
desktop/src/sw/source/uibase/uiview/view.css
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/* Writer frame layout: menu/toolbars on top, document + sidebar, status bar. */
|
||||
|
||||
.lo-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.lo-workarea {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.lo-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Ruler (svtools) */
|
||||
.svt-ruler {
|
||||
flex: none;
|
||||
padding: 2px 16px;
|
||||
background: var(--lo-bg);
|
||||
border-bottom: 1px solid var(--lo-border-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.svt-ruler-margin {
|
||||
fill: var(--lo-border-soft);
|
||||
}
|
||||
|
||||
.svt-ruler-text {
|
||||
fill: var(--lo-bg-field);
|
||||
}
|
||||
|
||||
.svt-ruler-tick {
|
||||
stroke: var(--lo-text-dim);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.svt-ruler-num {
|
||||
font-size: 9px;
|
||||
fill: var(--lo-text-dim);
|
||||
}
|
||||
|
||||
.svt-ruler-indent {
|
||||
fill: var(--lo-text-dim);
|
||||
}
|
||||
|
||||
/* Sidebar (sfx2) */
|
||||
.sfx-sidebar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
border-left: 1px solid var(--lo-border);
|
||||
background: var(--lo-bg);
|
||||
}
|
||||
|
||||
.sfx-deck {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--lo-border-soft);
|
||||
}
|
||||
|
||||
.sfx-deck-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 8px 8px 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid var(--lo-border-soft);
|
||||
}
|
||||
|
||||
.sfx-deck-title button {
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.sfx-deck-title button:hover {
|
||||
background: var(--lo-hover);
|
||||
}
|
||||
|
||||
.sfx-deck-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sfx-tabbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.sfx-stylelist-family {
|
||||
padding: 8px 12px 4px;
|
||||
color: var(--lo-text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sfx-stylelist ul,
|
||||
.sw-navigator ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 6px 8px;
|
||||
}
|
||||
|
||||
.sfx-stylelist li,
|
||||
.sw-navigator li {
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sfx-stylelist li:hover,
|
||||
.sw-navigator li:hover {
|
||||
background: var(--lo-hover);
|
||||
}
|
||||
|
||||
.sfx-stylelist li[aria-selected="true"],
|
||||
.sw-navigator li[aria-selected="true"] {
|
||||
background: var(--lo-checked);
|
||||
}
|
||||
|
||||
.sw-navigator-empty {
|
||||
margin: 4px 12px;
|
||||
color: var(--lo-text-dim);
|
||||
}
|
||||
|
||||
/* Word count dialog */
|
||||
.sw-wordcount {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.sw-wordcount th,
|
||||
.sw-wordcount td {
|
||||
padding: 4px 6px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sw-wordcount tbody th {
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.sw-wordcount thead th {
|
||||
color: var(--lo-text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
329
desktop/src/sw/source/uibase/uiview/view.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
/**
|
||||
* SwView — the Writer view (sw/source/uibase/uiview/view.cxx). Owns the
|
||||
* edit window, the shell stack for the dispatcher and the view-level UI
|
||||
* (rulers, sidebar, status bar, dialogs). The surrounding frame chrome
|
||||
* (menu bar, toolbars) is laid out here too, from sw/uiconfig/swriter.
|
||||
*/
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { AboutDialog, PRODUCT_NAME } from "../../../../cui/source/dialogs/about";
|
||||
import {
|
||||
ContextMenuManager,
|
||||
MenuBarManager,
|
||||
parseMenuXml,
|
||||
} from "../../../../framework/source/uielement/menubarmanager";
|
||||
import { parseToolBarXml, ToolBarManager } from "../../../../framework/source/uielement/toolbarmanager";
|
||||
import {
|
||||
ACCELERATORS,
|
||||
keyEventToAccel,
|
||||
} from "../../../../officecfg/registry/data/org/openoffice/Office/Accelerators";
|
||||
import { createAppShell } from "../../../../sfx2/source/appl/appserv";
|
||||
import { SfxDispatcher, SfxDispatcherContext } from "../../../../sfx2/source/control/dispatch";
|
||||
import { StyleList } from "../../../../sfx2/source/dialog/StyleList";
|
||||
import { startupDocument } from "../../../../sfx2/source/doc/docfile";
|
||||
import { Sidebar } from "../../../../sfx2/source/sidebar/SidebarController";
|
||||
import { SfxViewFrameContext, type SfxViewFrame } from "../../../../sfx2/source/view/viewfrm";
|
||||
import { Ruler } from "../../../../svtools/source/control/ruler";
|
||||
import { SvxSearchDialog, type SvxSearchItem } from "../../../../svx/source/dialog/srchdlg";
|
||||
import { ParaPropertyPanel } from "../../../../svx/source/sidebar/paragraph/ParaPropertyPanel";
|
||||
import { TextPropertyPanel } from "../../../../svx/source/sidebar/text/TextPropertyPanel";
|
||||
import { SvxZoomSliderControl } from "../../../../svx/source/stbctrls/zoomsliderctrl";
|
||||
import { MessageDialog } from "../../../../vcl/source/window/dialog";
|
||||
import { StatusBar, StatusBarItem } from "../../../../vcl/source/window/status";
|
||||
import { LIST_INDENT_CM } from "../../../../editeng/source/items/paraitem";
|
||||
import { hasSelection } from "../../../inc/pam";
|
||||
import { countDoc, countTexts } from "../../core/doc/DocumentStatisticsManager";
|
||||
import { getParaStyle } from "../../core/doc/DocumentStylePoolManager";
|
||||
import { pageOfNode } from "../../core/layout/layact";
|
||||
import { SwWordCountDialog } from "../../ui/dialog/wordcountdialog";
|
||||
import { createDocShellSlots, SwDocShell, type SfxInteraction } from "../app/docsh2";
|
||||
import { SwEditWin, type SwEditWinHandle } from "../docvw/edtwin";
|
||||
import { createTextShell } from "../shells/textsh";
|
||||
import { SwNavigator } from "../utlui/navipi";
|
||||
import { SwWrtShell } from "../wrtsh/wrtsh";
|
||||
import { createViewShell, DEFAULT_VIEW_UI, type SwViewDialog, type SwViewUi } from "./view2";
|
||||
import menubarXml from "../../../uiconfig/swriter/menubar/menubar.xml?raw";
|
||||
import textPopupXml from "../../../uiconfig/swriter/popupmenu/text.xml?raw";
|
||||
import standardbarXml from "../../../uiconfig/swriter/toolbar/standardbar.xml?raw";
|
||||
import textobjectbarXml from "../../../uiconfig/swriter/toolbar/textobjectbar.xml?raw";
|
||||
import "./view.css";
|
||||
|
||||
const MENUBAR = parseMenuXml(menubarXml);
|
||||
const TEXT_POPUP = parseMenuXml(textPopupXml);
|
||||
const STANDARDBAR = parseToolBarXml(standardbarXml);
|
||||
const TEXTOBJECTBAR = parseToolBarXml(textobjectbarXml);
|
||||
|
||||
interface MessageRequest {
|
||||
message: string;
|
||||
secondary: string;
|
||||
buttons: { id: string; label: string; default?: boolean }[];
|
||||
resolve: (id: string) => void;
|
||||
}
|
||||
|
||||
export function SwView() {
|
||||
const wrtShell = useMemo(() => new SwWrtShell(), []);
|
||||
const snapshot = useSyncExternalStore(wrtShell.subscribe, wrtShell.getSnapshot);
|
||||
const { doc, pam, modified } = snapshot;
|
||||
|
||||
const [ui, setUi] = useState<SwViewUi>(DEFAULT_VIEW_UI);
|
||||
const [dialog, setDialog] = useState<SwViewDialog | null>(null);
|
||||
const [message, setMessage] = useState<MessageRequest | null>(null);
|
||||
const [pageStarts, setPageStarts] = useState<number[]>([0]);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const editWin = useRef<SwEditWinHandle>(null);
|
||||
|
||||
const interaction = useMemo<SfxInteraction>(
|
||||
() => ({
|
||||
query: (msg, secondary, buttons) =>
|
||||
new Promise((resolve) => setMessage({ message: msg, secondary, buttons, resolve })),
|
||||
error: (msg) => {
|
||||
const [first, ...rest] = msg.split("\n");
|
||||
setMessage({
|
||||
message: first,
|
||||
secondary: rest.join("\n"),
|
||||
buttons: [{ id: "ok", label: "OK", default: true }],
|
||||
resolve: () => {},
|
||||
});
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const docShell = useMemo(() => new SwDocShell(wrtShell, interaction), [wrtShell, interaction]);
|
||||
const info = useSyncExternalStore(docShell.subscribe, docShell.getInfo);
|
||||
|
||||
const frame = useMemo<SfxViewFrame>(() => ({ grabFocus: () => editWin.current?.grabFocus() }), []);
|
||||
|
||||
// --- Shell stack & dispatcher (rebuilt per render, like SfxBindings::Invalidate) ---
|
||||
const dispatcher = new SfxDispatcher(
|
||||
[
|
||||
createTextShell(wrtShell, {
|
||||
copy: async (cut) => editWin.current?.copy(cut),
|
||||
paste: async (unformatted) => editWin.current?.paste(unformatted),
|
||||
}),
|
||||
createViewShell({
|
||||
ui,
|
||||
setUi,
|
||||
openDialog: setDialog,
|
||||
}),
|
||||
createDocShellSlots(docShell, wrtShell),
|
||||
createAppShell({
|
||||
newDocument: () => docShell.newDoc(),
|
||||
openDocument: () => docShell.open(),
|
||||
showAbout: () => setDialog("about"),
|
||||
}),
|
||||
],
|
||||
(command, error) => interaction.error(`${command} failed.\n${error}`),
|
||||
);
|
||||
const dispatcherRef = useRef(dispatcher);
|
||||
dispatcherRef.current = dispatcher;
|
||||
|
||||
// --- Window title, close handling, startup document --------------------------
|
||||
useEffect(() => {
|
||||
const title = `${info.title} - ${PRODUCT_NAME} Writer`;
|
||||
document.title = title;
|
||||
if (isTauri()) void getCurrentWindow().setTitle(title);
|
||||
}, [info.title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (wrtShell.getSnapshot().modified) e.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}
|
||||
const win = getCurrentWindow();
|
||||
const unlisten = win.onCloseRequested(async (event) => {
|
||||
if (!wrtShell.getSnapshot().modified) return;
|
||||
event.preventDefault();
|
||||
if (await docShell.prepareClose()) await win.destroy();
|
||||
});
|
||||
return () => void unlisten.then((f) => f());
|
||||
}, [wrtShell, docShell]);
|
||||
|
||||
useEffect(() => {
|
||||
void startupDocument().then((path) => {
|
||||
if (path) void docShell.openPath(path);
|
||||
});
|
||||
frame.grabFocus();
|
||||
}, [docShell, frame]);
|
||||
|
||||
// Accelerators while focus is outside the document (sidebar, toolbars).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const t = e.target as Element | null;
|
||||
if (t?.closest(".sw-pages, input, select, textarea, [role=dialog]")) return;
|
||||
const command = ACCELERATORS[keyEventToAccel(e)];
|
||||
if (command && dispatcherRef.current.execute(command)) e.preventDefault();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// --- Derived view state ----------------------------------------------------
|
||||
const docStat = useMemo(() => countDoc(doc), [doc]);
|
||||
const selStat = useMemo(
|
||||
() => (hasSelection(pam) ? countTexts(wrtShell.getSelectedText().split("\n")) : null),
|
||||
[pam, doc, wrtShell],
|
||||
);
|
||||
const currentPage = pageOfNode(pageStarts, pam.point.node) + 1;
|
||||
const para = wrtShell.getParaAttrs();
|
||||
const pointNode = doc.nodes[pam.point.node];
|
||||
const listIndent = pointNode.attrs.list ? (pointNode.attrs.listLevel ?? 1) * LIST_INDENT_CM : 0;
|
||||
|
||||
const onSearch = (item: SvxSearchItem): string => {
|
||||
const opts = { matchCase: item.matchCase, wholeWords: item.wholeWords };
|
||||
let found = true;
|
||||
switch (item.command) {
|
||||
case "find":
|
||||
found = wrtShell.find(item.searchString, opts);
|
||||
break;
|
||||
case "findPrevious":
|
||||
found = wrtShell.find(item.searchString, { ...opts, backwards: true });
|
||||
break;
|
||||
case "replace":
|
||||
found = wrtShell.replace(item.searchString, item.replaceString, opts);
|
||||
break;
|
||||
case "replaceAll": {
|
||||
const n = wrtShell.replaceAll(item.searchString, item.replaceString, opts);
|
||||
return n ? `${n} match${n === 1 ? "" : "es"} replaced` : "Search key not found";
|
||||
}
|
||||
}
|
||||
editWin.current?.showCursor();
|
||||
return found ? "" : "Search key not found";
|
||||
};
|
||||
|
||||
const gotoNode = (node: number) => {
|
||||
wrtShell.setSelection({ mark: { node, offset: 0 }, point: { node, offset: 0 } });
|
||||
frame.grabFocus();
|
||||
editWin.current?.showCursor();
|
||||
};
|
||||
|
||||
return (
|
||||
<SfxDispatcherContext value={dispatcher}>
|
||||
<SfxViewFrameContext value={frame}>
|
||||
<div className="lo-app">
|
||||
<MenuBarManager config={MENUBAR} />
|
||||
<ToolBarManager config={STANDARDBAR} label="Standard" />
|
||||
<ToolBarManager config={TEXTOBJECTBAR} label="Formatting" />
|
||||
<div className="lo-workarea">
|
||||
<div className="lo-main">
|
||||
{ui.ruler && (
|
||||
<Ruler
|
||||
pageWidth={doc.pageDesc.width}
|
||||
marginLeft={doc.pageDesc.marginLeft}
|
||||
marginRight={doc.pageDesc.marginRight}
|
||||
indentLeft={(para.leftMargin ?? 0) + listIndent}
|
||||
indentRight={para.rightMargin ?? 0}
|
||||
scale={ui.zoom / 100}
|
||||
/>
|
||||
)}
|
||||
<SwEditWin
|
||||
ref={editWin}
|
||||
shell={wrtShell}
|
||||
snapshot={snapshot}
|
||||
zoom={ui.zoom}
|
||||
showMarks={ui.marks}
|
||||
pageStarts={pageStarts}
|
||||
onLayout={setPageStarts}
|
||||
onCommand={(cmd) => dispatcher.execute(cmd)}
|
||||
onContextMenu={(x, y) => setContextMenu({ x, y })}
|
||||
/>
|
||||
</div>
|
||||
{ui.sidebar && (
|
||||
<Sidebar
|
||||
current={ui.deck}
|
||||
onDeck={(deck) => setUi((u) => ({ ...u, deck: deck as SwViewUi["deck"] }))}
|
||||
onClose={() => setUi((u) => ({ ...u, sidebar: false }))}
|
||||
decks={[
|
||||
{
|
||||
id: "properties",
|
||||
title: "Properties",
|
||||
icon: ".uno:SidebarDeck.PropertyDeck",
|
||||
content: (
|
||||
<>
|
||||
<TextPropertyPanel />
|
||||
<ParaPropertyPanel />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ id: "styles", title: "Styles", icon: ".uno:DesignerDialog", content: <StyleList /> },
|
||||
{
|
||||
id: "navigator",
|
||||
title: "Navigator",
|
||||
icon: ".uno:Navigator",
|
||||
content: <SwNavigator doc={doc} currentNode={pam.point.node} onGoto={gotoNode} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{ui.statusBar && (
|
||||
<StatusBar>
|
||||
<StatusBarItem width={110}>
|
||||
Page {currentPage} of {pageStarts.length}
|
||||
</StatusBarItem>
|
||||
<StatusBarItem width={200}>
|
||||
{selStat
|
||||
? `Selected: ${selStat.words} words, ${selStat.chars} characters`
|
||||
: `${docStat.words} words, ${docStat.chars} characters`}
|
||||
</StatusBarItem>
|
||||
<StatusBarItem width={150}>{doc.pageDesc.name}</StatusBarItem>
|
||||
<StatusBarItem width={130}>{getParaStyle(pointNode.style).uiName}</StatusBarItem>
|
||||
<StatusBarItem>English (USA)</StatusBarItem>
|
||||
<StatusBarItem flex title={modified ? "The document has been modified." : undefined}>
|
||||
{modified ? "● Modified" : ""}
|
||||
</StatusBarItem>
|
||||
<StatusBarItem>
|
||||
<SvxZoomSliderControl zoom={ui.zoom} onZoom={(zoom) => setUi((u) => ({ ...u, zoom }))} />
|
||||
</StatusBarItem>
|
||||
</StatusBar>
|
||||
)}
|
||||
|
||||
{contextMenu && (
|
||||
<ContextMenuManager
|
||||
config={TEXT_POPUP}
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
{dialog === "find" && (
|
||||
<SvxSearchDialog
|
||||
initialSearch={hasSelection(pam) && pam.mark.node === pam.point.node ? wrtShell.getSelectedText() : ""}
|
||||
onSearch={onSearch}
|
||||
onClose={() => {
|
||||
setDialog(null);
|
||||
frame.grabFocus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{dialog === "wordcount" && (
|
||||
<SwWordCountDialog
|
||||
doc={docStat}
|
||||
selection={selStat}
|
||||
pages={pageStarts.length}
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{dialog === "about" && <AboutDialog onClose={() => setDialog(null)} />}
|
||||
{message && (
|
||||
<MessageDialog
|
||||
title={PRODUCT_NAME}
|
||||
message={message.message}
|
||||
secondary={message.secondary}
|
||||
buttons={message.buttons}
|
||||
onResponse={(id) => {
|
||||
setMessage(null);
|
||||
message.resolve(id);
|
||||
frame.grabFocus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SfxViewFrameContext>
|
||||
</SfxDispatcherContext>
|
||||
);
|
||||
}
|
||||
|
||||
66
desktop/src/sw/source/uibase/uiview/view2.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* SwView slots — view settings, zoom, dialogs and printing
|
||||
* (sw/source/uibase/uiview/view2.cxx, viewport.cxx, viewprt.cxx).
|
||||
*/
|
||||
import type { SfxShell, SfxSlot } from "../../../../sfx2/source/control/dispatch";
|
||||
import { zoomIn, zoomOut } from "../../../../svx/source/stbctrls/zoomsliderctrl";
|
||||
|
||||
export type SwSidebarDeck = "properties" | "styles" | "navigator";
|
||||
export type SwViewDialog = "find" | "wordcount" | "about";
|
||||
|
||||
export interface SwViewUi {
|
||||
zoom: number;
|
||||
ruler: boolean;
|
||||
statusBar: boolean;
|
||||
sidebar: boolean;
|
||||
deck: SwSidebarDeck;
|
||||
/** View > Formatting Marks. */
|
||||
marks: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_VIEW_UI: SwViewUi = {
|
||||
zoom: 100,
|
||||
ruler: true,
|
||||
statusBar: true,
|
||||
sidebar: true,
|
||||
deck: "properties",
|
||||
marks: false,
|
||||
};
|
||||
|
||||
export interface SwViewContext {
|
||||
ui: SwViewUi;
|
||||
setUi: (update: (ui: SwViewUi) => SwViewUi) => void;
|
||||
openDialog: (dialog: SwViewDialog) => void;
|
||||
}
|
||||
|
||||
export function createViewShell({ ui, setUi, openDialog }: SwViewContext): SfxShell {
|
||||
const toggle = (key: "ruler" | "statusBar" | "sidebar" | "marks"): SfxSlot => ({
|
||||
exec: () => setUi((u) => ({ ...u, [key]: !u[key] })),
|
||||
state: () => ({ checked: ui[key] }),
|
||||
});
|
||||
const deck = (id: SwSidebarDeck): SfxSlot => ({
|
||||
exec: () =>
|
||||
setUi((u) => (u.sidebar && u.deck === id ? { ...u, sidebar: false } : { ...u, sidebar: true, deck: id })),
|
||||
state: () => ({ checked: ui.sidebar && ui.deck === id }),
|
||||
});
|
||||
const print = { exec: () => window.print() };
|
||||
return {
|
||||
name: "SwView",
|
||||
slots: {
|
||||
".uno:Ruler": toggle("ruler"),
|
||||
".uno:StatusBarVisible": toggle("statusBar"),
|
||||
".uno:Sidebar": toggle("sidebar"),
|
||||
".uno:ControlCodes": toggle("marks"),
|
||||
".uno:Navigator": deck("navigator"),
|
||||
".uno:DesignerDialog": deck("styles"),
|
||||
".uno:ZoomIn": { exec: () => setUi((u) => ({ ...u, zoom: zoomIn(u.zoom) })) },
|
||||
".uno:ZoomOut": { exec: () => setUi((u) => ({ ...u, zoom: zoomOut(u.zoom) })) },
|
||||
".uno:Zoom100Percent": { exec: () => setUi((u) => ({ ...u, zoom: 100 })) },
|
||||
".uno:SearchDialog": { exec: () => openDialog("find") },
|
||||
".uno:WordCountDialog": { exec: () => openDialog("wordcount") },
|
||||
".uno:Print": print,
|
||||
// No PDF writer yet: the print dialog offers "Save as PDF" on every platform.
|
||||
".uno:ExportDirectToPDF": print,
|
||||
},
|
||||
};
|
||||
}
|
||||
59
desktop/src/sw/source/uibase/utlui/navipi.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Navigator — counterpart of sw/source/uibase/utlui/navipi.cxx and the
|
||||
* content tree (content.cxx). Lists the document's headings; clicking one
|
||||
* moves the cursor there. Tables, images, bookmarks etc. come later.
|
||||
*/
|
||||
import { getNodeText } from "../../../inc/ndtxt";
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
import { getParaStyle } from "../../core/doc/DocumentStylePoolManager";
|
||||
|
||||
export interface SwNavigatorHeading {
|
||||
node: number;
|
||||
level: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function collectHeadings(doc: SwDoc): SwNavigatorHeading[] {
|
||||
const out: SwNavigatorHeading[] = [];
|
||||
doc.nodes.forEach((n, node) => {
|
||||
const level = getParaStyle(n.style).outlineLevel;
|
||||
if (level) out.push({ node, level, text: getNodeText(n).replace(/\s+/g, " ").trim() });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function SwNavigator({
|
||||
doc,
|
||||
currentNode,
|
||||
onGoto,
|
||||
}: {
|
||||
doc: SwDoc;
|
||||
currentNode: number;
|
||||
onGoto: (node: number) => void;
|
||||
}) {
|
||||
const headings = collectHeadings(doc);
|
||||
const current = [...headings].reverse().find((h) => h.node <= currentNode)?.node;
|
||||
return (
|
||||
<div className="sw-navigator">
|
||||
<div className="sfx-stylelist-family">Headings</div>
|
||||
{headings.length === 0 ? (
|
||||
<p className="sw-navigator-empty">No headings. Apply a Heading style (Ctrl+1, Ctrl+2, …) to build an outline.</p>
|
||||
) : (
|
||||
<ul role="tree" aria-label="Headings">
|
||||
{headings.map((h) => (
|
||||
<li
|
||||
key={h.node}
|
||||
role="treeitem"
|
||||
aria-selected={h.node === current}
|
||||
style={{ paddingLeft: 8 + (h.level - 1) * 14 }}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onGoto(h.node)}
|
||||
>
|
||||
{h.text || <em>(empty heading)</em>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
441
desktop/src/sw/source/uibase/wrtsh/wrtsh.ts
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
/**
|
||||
* SwWrtShell — the editing API the UI talks to (sw/source/uibase/wrtsh/).
|
||||
* Upstream it layers cursor handling (SwCursorShell), editing (SwEditShell)
|
||||
* and view concerns on top of SwDoc. Here it owns the current document
|
||||
* snapshot, the cursor (SwPaM), pending input attributes and undo, and lets
|
||||
* React subscribe to changes (useSyncExternalStore).
|
||||
*/
|
||||
import type { SvxCharAttrs } from "../../../../editeng/source/items/textitem";
|
||||
import {
|
||||
INDENT_STEP_CM,
|
||||
MAX_LIST_LEVEL,
|
||||
type SvxParaAttrs,
|
||||
type SwListKind,
|
||||
} from "../../../../editeng/source/items/paraitem";
|
||||
import type { SwDoc } from "../../../inc/doc";
|
||||
import { getNodeText, nodeLength, type SwTextNode } from "../../../inc/ndtxt";
|
||||
import {
|
||||
clampPaM,
|
||||
collapsed,
|
||||
docEnd,
|
||||
hasSelection,
|
||||
pamEnd,
|
||||
pamEquals,
|
||||
pamStart,
|
||||
pos,
|
||||
posEquals,
|
||||
type SwPaM,
|
||||
type SwPosition,
|
||||
} from "../../../inc/pam";
|
||||
import { findAll, findText, type SvxSearchOptions } from "../../core/crsr/findtxt";
|
||||
import {
|
||||
copyRange,
|
||||
deleteRange,
|
||||
insertNodes,
|
||||
insertText,
|
||||
splitNode,
|
||||
} from "../../core/doc/DocumentContentOperationsManager";
|
||||
import {
|
||||
effectiveCharAttrs,
|
||||
effectiveParaAttrs,
|
||||
} from "../../core/doc/DocumentStylePoolManager";
|
||||
import {
|
||||
formatParas,
|
||||
resetCharAttrs,
|
||||
setCharAttrs,
|
||||
setParaAttrs,
|
||||
setParaStyle,
|
||||
} from "../../core/doc/docfmt";
|
||||
import { createDoc } from "../../core/doc/docnew";
|
||||
import { attrsAt, attrsOfCharAt } from "../../core/txtnode/thints";
|
||||
import { SwUndoManager } from "../../core/undo/UndoManager";
|
||||
|
||||
export interface SwShellSnapshot {
|
||||
readonly doc: SwDoc;
|
||||
readonly pam: SwPaM;
|
||||
/** Attributes set with a collapsed cursor, applied to the next input. */
|
||||
readonly pending: SvxCharAttrs | null;
|
||||
readonly modified: boolean;
|
||||
readonly undoComment?: string;
|
||||
readonly redoComment?: string;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const graphemes =
|
||||
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
: null;
|
||||
|
||||
function prevBoundary(text: string, offset: number): number {
|
||||
if (!graphemes) return offset - 1;
|
||||
let prev = 0;
|
||||
for (const seg of graphemes.segment(text)) {
|
||||
if (seg.index >= offset) break;
|
||||
prev = seg.index;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
function nextBoundary(text: string, offset: number): number {
|
||||
if (!graphemes) return offset + 1;
|
||||
for (const seg of graphemes.segment(text)) {
|
||||
if (seg.index > offset) return seg.index;
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
const WORD_CHAR = /[\p{L}\p{N}_]/u;
|
||||
|
||||
/** Word around a collapsed cursor, if the cursor is strictly inside one. */
|
||||
function wordAround(text: string, offset: number): [number, number] | null {
|
||||
if (offset === 0 || offset >= text.length) return null;
|
||||
if (!WORD_CHAR.test(text[offset - 1]) || !WORD_CHAR.test(text[offset])) return null;
|
||||
let s = offset;
|
||||
let e = offset;
|
||||
while (s > 0 && WORD_CHAR.test(text[s - 1])) s--;
|
||||
while (e < text.length && WORD_CHAR.test(text[e])) e++;
|
||||
return [s, e];
|
||||
}
|
||||
|
||||
export class SwWrtShell {
|
||||
private state: SwShellSnapshot;
|
||||
private readonly listeners = new Set<Listener>();
|
||||
private readonly undoManager = new SwUndoManager();
|
||||
|
||||
constructor(doc: SwDoc = createDoc()) {
|
||||
this.state = { doc, pam: collapsed(pos(0, 0)), pending: null, modified: false };
|
||||
}
|
||||
|
||||
// --- React store plumbing -------------------------------------------------
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
getSnapshot = (): SwShellSnapshot => this.state;
|
||||
|
||||
get doc(): SwDoc {
|
||||
return this.state.doc;
|
||||
}
|
||||
|
||||
get pam(): SwPaM {
|
||||
return this.state.pam;
|
||||
}
|
||||
|
||||
private commit(
|
||||
next: { doc?: SwDoc; pam?: SwPaM; pending?: SvxCharAttrs | null; modified?: boolean },
|
||||
undo?: { comment: string; group?: string },
|
||||
): void {
|
||||
if (undo) {
|
||||
this.undoManager.record({ doc: this.state.doc, pam: this.state.pam }, undo.comment, undo.group);
|
||||
}
|
||||
const doc = next.doc ?? this.state.doc;
|
||||
this.state = {
|
||||
doc,
|
||||
pam: clampPaM(doc, next.pam ?? this.state.pam),
|
||||
pending: next.pending === undefined ? this.state.pending : next.pending,
|
||||
modified: next.modified ?? (undo ? true : this.state.modified),
|
||||
undoComment: this.undoManager.undoComment,
|
||||
redoComment: this.undoManager.redoComment,
|
||||
};
|
||||
for (const l of this.listeners) l();
|
||||
}
|
||||
|
||||
/** Replaces the document (File > New / Open). */
|
||||
load(doc: SwDoc): void {
|
||||
this.undoManager.clear();
|
||||
this.commit({ doc, pam: collapsed(pos(0, 0)), pending: null, modified: false });
|
||||
}
|
||||
|
||||
setModified(modified: boolean): void {
|
||||
if (modified !== this.state.modified) this.commit({ modified });
|
||||
}
|
||||
|
||||
// --- Cursor ---------------------------------------------------------------
|
||||
|
||||
setSelection(pam: SwPaM): void {
|
||||
const next = clampPaM(this.state.doc, pam);
|
||||
if (pamEquals(next, this.state.pam)) return;
|
||||
this.undoManager.breakGroup();
|
||||
this.commit({ pam: next, pending: null });
|
||||
}
|
||||
|
||||
selectAll(): void {
|
||||
this.setSelection({ mark: pos(0, 0), point: docEnd(this.state.doc) });
|
||||
}
|
||||
|
||||
getSelectedText(): string {
|
||||
return copyRange(this.state.doc, this.state.pam).map(getNodeText).join("\n");
|
||||
}
|
||||
|
||||
// --- Text input -----------------------------------------------------------
|
||||
|
||||
/** Types `text` over the selection (SwWrtShell::Insert). */
|
||||
insert(text: string): void {
|
||||
this.replaceRange(this.state.pam, text, "Typing", "typing");
|
||||
}
|
||||
|
||||
/** Replaces `pam` with `text` using the attributes at its start. */
|
||||
replaceRange(pam: SwPaM, text: string, comment = "Replace", group?: string): void {
|
||||
const del = deleteRange(this.state.doc, pam);
|
||||
const node = del.doc.nodes[del.pos.node];
|
||||
const base = hasSelection(pam)
|
||||
? attrsOfCharAt(this.state.doc.nodes[pamStart(pam).node].runs, pamStart(pam).offset)
|
||||
: attrsAt(node.runs, del.pos.offset);
|
||||
const attrs = this.state.pending ? { ...base, ...this.state.pending } : base;
|
||||
const ins = text ? insertText(del.doc, del.pos, text, attrs) : del;
|
||||
this.commit({ doc: ins.doc, pam: collapsed(ins.pos), pending: null }, { comment, group });
|
||||
}
|
||||
|
||||
/** Enter (SwWrtShell::SplitNode). An empty list item leaves the list. */
|
||||
splitNode(): void {
|
||||
const { doc, pam } = this.state;
|
||||
const node = doc.nodes[pam.point.node];
|
||||
if (!hasSelection(pam) && node.attrs.list && nodeLength(node) === 0) {
|
||||
this.setListKind(undefined);
|
||||
return;
|
||||
}
|
||||
const del = deleteRange(doc, pam);
|
||||
const split = splitNode(del.doc, del.pos);
|
||||
this.commit({ doc: split.doc, pam: collapsed(split.pos) }, { comment: "New paragraph" });
|
||||
}
|
||||
|
||||
/** Shift+Enter. */
|
||||
insertLineBreak(): void {
|
||||
this.replaceRange(this.state.pam, "\n", "Insert line break");
|
||||
}
|
||||
|
||||
/** Ctrl+Enter (SwWrtShell::InsertPageBreak). */
|
||||
insertPageBreak(): void {
|
||||
const del = deleteRange(this.state.doc, this.state.pam);
|
||||
const split = splitNode(del.doc, del.pos);
|
||||
const doc = setParaAttrs(split.doc, collapsed(split.pos), { pageBreakBefore: true });
|
||||
this.commit({ doc, pam: collapsed(split.pos) }, { comment: "Insert page break" });
|
||||
}
|
||||
|
||||
/** Backspace (SwWrtShell::DelLeft). */
|
||||
delLeft(): void {
|
||||
const { doc, pam } = this.state;
|
||||
if (hasSelection(pam)) return this.deleteRange(pam);
|
||||
const p = pam.point;
|
||||
const node = doc.nodes[p.node];
|
||||
if (p.offset > 0) {
|
||||
const from = prevBoundary(getNodeText(node), p.offset);
|
||||
return this.deleteRange({ mark: pos(p.node, from), point: p }, "typing");
|
||||
}
|
||||
// At the paragraph start: leave the list first, then drop direct
|
||||
// indentation / page break, then join with the previous paragraph.
|
||||
if (node.attrs.list) return this.setListKind(undefined);
|
||||
if (p.node > 0) {
|
||||
const prev = doc.nodes[p.node - 1];
|
||||
this.deleteRange({ mark: pos(p.node - 1, nodeLength(prev)), point: p });
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete (SwWrtShell::DelRight). */
|
||||
delRight(): void {
|
||||
const { doc, pam } = this.state;
|
||||
if (hasSelection(pam)) return this.deleteRange(pam);
|
||||
const p = pam.point;
|
||||
const node = doc.nodes[p.node];
|
||||
const len = nodeLength(node);
|
||||
if (p.offset < len) {
|
||||
const to = nextBoundary(getNodeText(node), p.offset);
|
||||
return this.deleteRange({ mark: p, point: pos(p.node, to) }, "delete");
|
||||
}
|
||||
if (p.node < doc.nodes.length - 1) this.deleteRange({ mark: p, point: pos(p.node + 1, 0) });
|
||||
}
|
||||
|
||||
deleteRange(pam: SwPaM, group?: string): void {
|
||||
if (!hasSelection(pam)) return;
|
||||
const res = deleteRange(this.state.doc, pam);
|
||||
this.commit({ doc: res.doc, pam: collapsed(res.pos), pending: null }, { comment: "Delete", group });
|
||||
}
|
||||
|
||||
// --- Clipboard ------------------------------------------------------------
|
||||
|
||||
copySelection(): SwTextNode[] {
|
||||
return copyRange(this.state.doc, this.state.pam);
|
||||
}
|
||||
|
||||
/** Pastes a paragraph fragment over the selection. */
|
||||
insertFragment(fragment: readonly SwTextNode[], comment = "Paste"): void {
|
||||
if (fragment.length === 0) return;
|
||||
const del = deleteRange(this.state.doc, this.state.pam);
|
||||
const ins = insertNodes(del.doc, del.pos, fragment);
|
||||
this.commit({ doc: ins.doc, pam: collapsed(ins.pos), pending: null }, { comment });
|
||||
}
|
||||
|
||||
/** Pastes plain text; line breaks become paragraphs in the current style. */
|
||||
insertPlainText(text: string): void {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
if (lines.length === 1) return this.replaceRange(this.state.pam, text, "Paste");
|
||||
const start = pamStart(this.state.pam);
|
||||
const cur = this.state.doc.nodes[start.node];
|
||||
const attrs = attrsAt(cur.runs, start.offset);
|
||||
const { pageBreakBefore: _pb, ...paraAttrs } = cur.attrs;
|
||||
this.insertFragment(
|
||||
lines.map((line) => ({
|
||||
id: "",
|
||||
style: cur.style,
|
||||
attrs: paraAttrs,
|
||||
runs: [{ text: line, attrs }],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Character attributes -------------------------------------------------
|
||||
|
||||
/** Effective character attributes at the cursor (style + direct + pending). */
|
||||
getCharAttrs(): SvxCharAttrs {
|
||||
const { doc, pam, pending } = this.state;
|
||||
const start = hasSelection(pam) ? pamStart(pam) : pam.point;
|
||||
const node = doc.nodes[start.node];
|
||||
const direct = hasSelection(pam)
|
||||
? attrsOfCharAt(node.runs, start.offset)
|
||||
: attrsAt(node.runs, start.offset);
|
||||
return effectiveCharAttrs(node.style, { ...direct, ...pending });
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies attributes to the selection. With a collapsed cursor inside a
|
||||
* word Writer formats the whole word; otherwise the attributes are kept
|
||||
* pending for the next input.
|
||||
*/
|
||||
setCharAttrs(patch: SvxCharAttrs, comment = "Apply attributes"): void {
|
||||
const target = this.formatTarget();
|
||||
if (!target) {
|
||||
this.commit({ pending: { ...this.state.pending, ...patch } });
|
||||
return;
|
||||
}
|
||||
this.commit({ doc: setCharAttrs(this.state.doc, target, patch) }, { comment });
|
||||
}
|
||||
|
||||
toggleCharAttr(key: "bold" | "italic" | "underline" | "strikeout"): void {
|
||||
this.setCharAttrs({ [key]: !this.getCharAttrs()[key] });
|
||||
}
|
||||
|
||||
toggleEscapement(kind: "super" | "sub"): void {
|
||||
this.setCharAttrs({ escapement: this.getCharAttrs().escapement === kind ? "none" : kind });
|
||||
}
|
||||
|
||||
/** Format > Clear Direct Formatting. */
|
||||
resetCharAttrs(): void {
|
||||
const target = this.formatTarget();
|
||||
if (!target) {
|
||||
this.commit({ pending: null });
|
||||
return;
|
||||
}
|
||||
this.commit(
|
||||
{ doc: resetCharAttrs(this.state.doc, target), pending: null },
|
||||
{ comment: "Reset attributes" },
|
||||
);
|
||||
}
|
||||
|
||||
private formatTarget(): SwPaM | null {
|
||||
const { doc, pam } = this.state;
|
||||
if (hasSelection(pam)) return pam;
|
||||
const word = wordAround(getNodeText(doc.nodes[pam.point.node]), pam.point.offset);
|
||||
return word ? { mark: pos(pam.point.node, word[0]), point: pos(pam.point.node, word[1]) } : null;
|
||||
}
|
||||
|
||||
// --- Paragraph attributes -------------------------------------------------
|
||||
|
||||
getParaAttrs(): SvxParaAttrs {
|
||||
return effectiveParaAttrs(this.state.doc.nodes[this.state.pam.point.node]);
|
||||
}
|
||||
|
||||
getParaStyle(): string {
|
||||
return this.state.doc.nodes[this.state.pam.point.node].style;
|
||||
}
|
||||
|
||||
setParaAttrs(patch: SvxParaAttrs, comment = "Apply paragraph attributes"): void {
|
||||
this.commit({ doc: setParaAttrs(this.state.doc, this.state.pam, patch) }, { comment });
|
||||
}
|
||||
|
||||
setParaStyle(style: string): void {
|
||||
this.commit({ doc: setParaStyle(this.state.doc, this.state.pam, style) }, { comment: "Apply Styles" });
|
||||
}
|
||||
|
||||
/** Numbering on/off (FN_NUM_BULLET_ON / FN_NUM_NUMBERING_ON). */
|
||||
toggleList(kind: SwListKind): void {
|
||||
const { doc, pam } = this.state;
|
||||
const s = pamStart(pam).node;
|
||||
const e = pamEnd(pam).node;
|
||||
const all = doc.nodes.slice(s, e + 1).every((n) => n.attrs.list === kind);
|
||||
this.setListKind(all ? undefined : kind);
|
||||
}
|
||||
|
||||
private setListKind(kind: SwListKind | undefined): void {
|
||||
const doc = formatParas(this.state.doc, this.state.pam, (n) => ({
|
||||
...n,
|
||||
attrs: { ...n.attrs, list: kind, listLevel: kind ? (n.attrs.listLevel ?? 1) : undefined },
|
||||
}));
|
||||
this.commit({ doc }, { comment: kind ? "Apply numbering" : "Numbering off" });
|
||||
}
|
||||
|
||||
/** Increase/Decrease Indent; demotes/promotes list items. */
|
||||
changeIndent(delta: 1 | -1): void {
|
||||
const doc = formatParas(this.state.doc, this.state.pam, (n) => {
|
||||
if (n.attrs.list) {
|
||||
const level = Math.max(1, Math.min(MAX_LIST_LEVEL, (n.attrs.listLevel ?? 1) + delta));
|
||||
return { ...n, attrs: { ...n.attrs, listLevel: level } };
|
||||
}
|
||||
const lm = Math.max(0, (n.attrs.leftMargin ?? 0) + delta * INDENT_STEP_CM);
|
||||
return { ...n, attrs: { ...n.attrs, leftMargin: lm || undefined } };
|
||||
});
|
||||
this.commit({ doc }, { comment: "Indent" });
|
||||
}
|
||||
|
||||
// --- Undo -----------------------------------------------------------------
|
||||
|
||||
undo(): void {
|
||||
const prev = this.undoManager.undo({ doc: this.state.doc, pam: this.state.pam });
|
||||
if (prev) this.commit({ doc: prev.doc, pam: prev.pam, pending: null, modified: true });
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
const next = this.undoManager.redo({ doc: this.state.doc, pam: this.state.pam });
|
||||
if (next) this.commit({ doc: next.doc, pam: next.pam, pending: null, modified: true });
|
||||
}
|
||||
|
||||
// --- Find & Replace -------------------------------------------------------
|
||||
|
||||
find(needle: string, opts: SvxSearchOptions = {}): boolean {
|
||||
const { doc, pam } = this.state;
|
||||
const from: SwPosition = opts.backwards ? pamStart(pam) : pamEnd(pam);
|
||||
const hit = findText(doc, from, needle, opts);
|
||||
if (hit) this.setSelection(hit);
|
||||
return !!hit;
|
||||
}
|
||||
|
||||
/** Replaces the current match (if selected) and finds the next one. */
|
||||
replace(needle: string, replacement: string, opts: SvxSearchOptions = {}): boolean {
|
||||
const { doc, pam } = this.state;
|
||||
if (hasSelection(pam)) {
|
||||
const s = pamStart(pam);
|
||||
const hit = findText(doc, s, needle, { ...opts, backwards: false });
|
||||
if (hit && posEquals(pamStart(hit), s) && posEquals(pamEnd(hit), pamEnd(pam))) {
|
||||
this.replaceRange(pam, replacement, "Replace");
|
||||
}
|
||||
}
|
||||
return this.find(needle, opts);
|
||||
}
|
||||
|
||||
/** Replaces every match in one undo step; returns the count. */
|
||||
replaceAll(needle: string, replacement: string, opts: SvxSearchOptions = {}): number {
|
||||
const hits = findAll(this.state.doc, needle, opts);
|
||||
if (hits.length === 0) return 0;
|
||||
let doc = this.state.doc;
|
||||
for (const hit of [...hits].reverse()) {
|
||||
const s = pamStart(hit);
|
||||
const attrs = attrsOfCharAt(doc.nodes[s.node].runs, s.offset);
|
||||
const del = deleteRange(doc, hit);
|
||||
doc = replacement ? insertText(del.doc, del.pos, replacement, attrs).doc : del.doc;
|
||||
}
|
||||
this.commit({ doc, pam: collapsed(pamStart(hits[0])), pending: null }, { comment: "Replace all" });
|
||||
return hits.length;
|
||||
}
|
||||
}
|
||||
182
desktop/src/sw/uiconfig/swriter/menubar/menubar.xml
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Writer menu bar, in the same format as upstream
|
||||
core/sw/uiconfig/swriter/menubar/menubar.xml (trimmed to the commands this
|
||||
skeleton knows). Commands without an implementation show up disabled.
|
||||
-->
|
||||
<menu:menubar xmlns:menu="http://openoffice.org/2001/menu" menu:id="menubar">
|
||||
<menu:menu menu:id=".uno:PickList">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:AddDirect"/>
|
||||
<menu:menuitem menu:id=".uno:Open"/>
|
||||
<menu:menuitem menu:id=".uno:RecentFileList"/>
|
||||
<menu:menuitem menu:id=".uno:CloseDoc"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:Save"/>
|
||||
<menu:menuitem menu:id=".uno:SaveAs"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menu menu:id=".uno:ExportAsMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:ExportDirectToPDF"/>
|
||||
<menu:menuitem menu:id=".uno:ExportToHTML"/>
|
||||
<menu:menuitem menu:id=".uno:ExportToText"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:Print"/>
|
||||
<menu:menuitem menu:id=".uno:PrinterSetup"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:SetDocumentProperties"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:Quit"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:EditMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:Undo"/>
|
||||
<menu:menuitem menu:id=".uno:Redo"/>
|
||||
<menu:menuitem menu:id=".uno:Repeat"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:Cut"/>
|
||||
<menu:menuitem menu:id=".uno:Copy"/>
|
||||
<menu:menuitem menu:id=".uno:Paste"/>
|
||||
<menu:menuitem menu:id=".uno:PasteUnformatted"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:SelectAll"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:SearchDialog"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:TrackChanges"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:ViewMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:Ruler"/>
|
||||
<menu:menuitem menu:id=".uno:StatusBarVisible"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:ControlCodes"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:Sidebar"/>
|
||||
<menu:menuitem menu:id=".uno:Navigator"/>
|
||||
<menu:menuitem menu:id=".uno:DesignerDialog"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:FullScreen"/>
|
||||
<menu:menu menu:id=".uno:ZoomMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:ZoomIn"/>
|
||||
<menu:menuitem menu:id=".uno:ZoomOut"/>
|
||||
<menu:menuitem menu:id=".uno:Zoom100Percent"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:InsertMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:InsertPagebreak"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:InsertGraphic"/>
|
||||
<menu:menuitem menu:id=".uno:InsertObjectChart"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:InsertAnnotation"/>
|
||||
<menu:menuitem menu:id=".uno:HyperlinkDialog"/>
|
||||
<menu:menuitem menu:id=".uno:CharmapControl"/>
|
||||
<menu:menuitem menu:id=".uno:InsertFootnote"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menu menu:id=".uno:InsertField">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:InsertDateField"/>
|
||||
<menu:menuitem menu:id=".uno:InsertTimeField"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:FormatMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menu menu:id=".uno:FormatTextMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:Bold"/>
|
||||
<menu:menuitem menu:id=".uno:Italic"/>
|
||||
<menu:menuitem menu:id=".uno:Underline"/>
|
||||
<menu:menuitem menu:id=".uno:Strikeout"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:SuperScript"/>
|
||||
<menu:menuitem menu:id=".uno:SubScript"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:AlignTextMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:LeftPara"/>
|
||||
<menu:menuitem menu:id=".uno:CenterPara"/>
|
||||
<menu:menuitem menu:id=".uno:RightPara"/>
|
||||
<menu:menuitem menu:id=".uno:JustifyPara"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:FormatBulletsMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:DefaultBullet"/>
|
||||
<menu:menuitem menu:id=".uno:DefaultNumbering"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:IncrementIndent"/>
|
||||
<menu:menuitem menu:id=".uno:DecrementIndent"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:ResetAttributes"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:FontDialog"/>
|
||||
<menu:menuitem menu:id=".uno:ParagraphDialog"/>
|
||||
<menu:menuitem menu:id=".uno:BulletsAndNumberingDialog"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:PageDialog"/>
|
||||
<menu:menuitem menu:id=".uno:FormatColumns"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:FormatStylesMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:DefaultParaStyle"/>
|
||||
<menu:menuitem menu:id=".uno:TitleParaStyle"/>
|
||||
<menu:menuitem menu:id=".uno:SubtitleParaStyle"/>
|
||||
<menu:menuitem menu:id=".uno:Heading1ParaStyle"/>
|
||||
<menu:menuitem menu:id=".uno:Heading2ParaStyle"/>
|
||||
<menu:menuitem menu:id=".uno:Heading3ParaStyle"/>
|
||||
<menu:menuitem menu:id=".uno:TextBodyParaStyle"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:QuoteParaStyle"/>
|
||||
<menu:menuitem menu:id=".uno:PreformattedParaStyle"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:DesignerDialog"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:TableMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:InsertTable"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:InsertRowsAfter"/>
|
||||
<menu:menuitem menu:id=".uno:InsertColumnsAfter"/>
|
||||
<menu:menuitem menu:id=".uno:DeleteTable"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:ToolsMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:SpellingAndGrammarDialog"/>
|
||||
<menu:menuitem menu:id=".uno:WordCountDialog"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:AutoCorrectDlg"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:OptionsTreeDialog"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:WindowList">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:NewWindow"/>
|
||||
<menu:menuitem menu:id=".uno:CloseWin"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:HelpMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:HelpIndex"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:About"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
</menu:menubar>
|
||||
43
desktop/src/sw/uiconfig/swriter/popupmenu/text.xml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Text context menu; format of core/sw/uiconfig/swriter/popupmenu/text.xml -->
|
||||
<menu:menupopup xmlns:menu="http://openoffice.org/2001/menu">
|
||||
<menu:menuitem menu:id=".uno:Cut"/>
|
||||
<menu:menuitem menu:id=".uno:Copy"/>
|
||||
<menu:menuitem menu:id=".uno:Paste"/>
|
||||
<menu:menuitem menu:id=".uno:PasteUnformatted"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menu menu:id=".uno:FormatTextMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:Bold"/>
|
||||
<menu:menuitem menu:id=".uno:Italic"/>
|
||||
<menu:menuitem menu:id=".uno:Underline"/>
|
||||
<menu:menuitem menu:id=".uno:Strikeout"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:SuperScript"/>
|
||||
<menu:menuitem menu:id=".uno:SubScript"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:AlignTextMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:LeftPara"/>
|
||||
<menu:menuitem menu:id=".uno:CenterPara"/>
|
||||
<menu:menuitem menu:id=".uno:RightPara"/>
|
||||
<menu:menuitem menu:id=".uno:JustifyPara"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menu menu:id=".uno:FormatBulletsMenu">
|
||||
<menu:menupopup>
|
||||
<menu:menuitem menu:id=".uno:DefaultBullet"/>
|
||||
<menu:menuitem menu:id=".uno:DefaultNumbering"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:IncrementIndent"/>
|
||||
<menu:menuitem menu:id=".uno:DecrementIndent"/>
|
||||
</menu:menupopup>
|
||||
</menu:menu>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:ResetAttributes"/>
|
||||
<menu:menuitem menu:id=".uno:FontDialog"/>
|
||||
<menu:menuitem menu:id=".uno:ParagraphDialog"/>
|
||||
<menu:menuseparator/>
|
||||
<menu:menuitem menu:id=".uno:SelectAll"/>
|
||||
</menu:menupopup>
|
||||
32
desktop/src/sw/uiconfig/swriter/toolbar/standardbar.xml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Standard toolbar; format of core/sw/uiconfig/swriter/toolbar/standardbar.xml -->
|
||||
<toolbar:toolbar xmlns:toolbar="http://openoffice.org/2001/toolbar" xmlns:xlink="http://www.w3.org/1999/xlink" toolbar:id="toolbar">
|
||||
<toolbar:toolbaritem xlink:href=".uno:AddDirect"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:Open"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:Save"/>
|
||||
<toolbar:toolbarseparator/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:ExportDirectToPDF"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:Print"/>
|
||||
<toolbar:toolbarseparator/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:Cut"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:Copy"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:Paste"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:FormatPaintbrush"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:ResetAttributes"/>
|
||||
<toolbar:toolbarseparator/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:Undo"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:Redo"/>
|
||||
<toolbar:toolbarseparator/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:SearchDialog"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:SpellingAndGrammarDialog"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:ControlCodes"/>
|
||||
<toolbar:toolbarseparator/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:InsertTable"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:InsertGraphic"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:InsertObjectChart"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:CharmapControl"/>
|
||||
<toolbar:toolbarseparator/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:InsertPagebreak"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:HyperlinkDialog"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:InsertAnnotation"/>
|
||||
</toolbar:toolbar>
|
||||
29
desktop/src/sw/uiconfig/swriter/toolbar/textobjectbar.xml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Formatting toolbar; format of core/sw/uiconfig/swriter/toolbar/textobjectbar.xml -->
|
||||
<toolbar:toolbar xmlns:toolbar="http://openoffice.org/2001/toolbar" xmlns:xlink="http://www.w3.org/1999/xlink" toolbar:id="toolbar">
|
||||
<toolbar:toolbaritem xlink:href=".uno:StyleApply"/>
|
||||
<toolbar:toolbarseparator/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:CharFontName"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:FontHeight"/>
|
||||
<toolbar:toolbarseparator/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:Bold"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:Italic"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:Underline"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:Strikeout"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:SuperScript"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:SubScript"/>
|
||||
<toolbar:toolbarseparator/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:FontColor"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:CharBackColor"/>
|
||||
<toolbar:toolbarseparator/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:LeftPara"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:CenterPara"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:RightPara"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:JustifyPara"/>
|
||||
<toolbar:toolbarseparator/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:DefaultBullet"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:DefaultNumbering"/>
|
||||
<toolbar:toolbarseparator/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:IncrementIndent"/>
|
||||
<toolbar:toolbaritem xlink:href=".uno:DecrementIndent"/>
|
||||
</toolbar:toolbar>
|
||||
48
desktop/src/unotools/source/misc/fontdefs.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Font substitution — like unotools/source/misc/fontdefs.cxx and VCL's
|
||||
* replacement table, maps LibreOffice's default fonts onto metric-compatible
|
||||
* fonts likely to be installed, ending in a CSS generic family.
|
||||
*/
|
||||
const SUBSTITUTES: Record<string, string[]> = {
|
||||
"Liberation Serif": ["Times New Roman", "Times", "serif"],
|
||||
"Liberation Sans": ["Arial", "Helvetica", "sans-serif"],
|
||||
"Liberation Mono": ["Courier New", "Menlo", "monospace"],
|
||||
"DejaVu Sans": ["Verdana", "sans-serif"],
|
||||
"DejaVu Serif": ["Georgia", "serif"],
|
||||
"Noto Sans": ["Helvetica", "Arial", "sans-serif"],
|
||||
"Noto Serif": ["Georgia", "serif"],
|
||||
};
|
||||
|
||||
export function cssFontFamily(name: string | undefined): string {
|
||||
if (!name) return "serif";
|
||||
const chain = [name, ...(SUBSTITUTES[name] ?? ["sans-serif"])];
|
||||
return chain
|
||||
.map((f) => (["serif", "sans-serif", "monospace"].includes(f) ? f : `"${f}"`))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/** Fonts offered in the font name box. */
|
||||
export const FONT_LIST: readonly string[] = [
|
||||
"Liberation Serif",
|
||||
"Liberation Sans",
|
||||
"Liberation Mono",
|
||||
"Arial",
|
||||
"Courier New",
|
||||
"DejaVu Sans",
|
||||
"DejaVu Serif",
|
||||
"Georgia",
|
||||
"Helvetica",
|
||||
"Menlo",
|
||||
"Noto Sans",
|
||||
"Noto Serif",
|
||||
"Palatino",
|
||||
"Times New Roman",
|
||||
"Trebuchet MS",
|
||||
"Verdana",
|
||||
];
|
||||
|
||||
/** Sizes offered in the font size box (FontSizeNames / FontList::GetStdSizeAry). */
|
||||
export const FONT_SIZES: readonly number[] = [
|
||||
6, 7, 8, 9, 10, 10.5, 11, 12, 13, 14, 15, 16, 18, 20, 22, 24, 26, 28, 32, 36, 40, 44, 48, 54,
|
||||
60, 66, 72, 80, 88, 96,
|
||||
];
|
||||
455
desktop/src/vcl/source/app/vcl.css
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
/*
|
||||
* VCL widget look — the role of vcl's style settings / native widget
|
||||
* drawing. Colours are tokens so the chrome follows the system theme; the
|
||||
* document canvas itself stays paper-white, as in LibreOffice.
|
||||
*/
|
||||
:root {
|
||||
--lo-bg: #efefef;
|
||||
--lo-bg-raised: #fbfbfb;
|
||||
--lo-bg-field: #ffffff;
|
||||
--lo-border: #c8c8c8;
|
||||
--lo-border-soft: #dcdcdc;
|
||||
--lo-text: #1e1e1e;
|
||||
--lo-text-dim: #6a6a6a;
|
||||
--lo-text-disabled: #a6a6a6;
|
||||
--lo-hover: #dddddd;
|
||||
--lo-pressed: #cdcdcd;
|
||||
--lo-checked: #cfdff3;
|
||||
--lo-checked-border: #8eb1dd;
|
||||
--lo-accent: #1c5fa8;
|
||||
--lo-accent-text: #ffffff;
|
||||
--lo-app-bg: #d5d5d5;
|
||||
--lo-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
--lo-popup-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
--lo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", "Cantarell", sans-serif;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--lo-bg: #2c2c2c;
|
||||
--lo-bg-raised: #333333;
|
||||
--lo-bg-field: #1f1f1f;
|
||||
--lo-border: #4a4a4a;
|
||||
--lo-border-soft: #3d3d3d;
|
||||
--lo-text: #e8e8e8;
|
||||
--lo-text-dim: #a0a0a0;
|
||||
--lo-text-disabled: #6a6a6a;
|
||||
--lo-hover: #3d3d3d;
|
||||
--lo-pressed: #474747;
|
||||
--lo-checked: #2f4a6b;
|
||||
--lo-checked-border: #4d78ad;
|
||||
--lo-accent: #4a8fd8;
|
||||
--lo-app-bg: #1b1b1b;
|
||||
--lo-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
|
||||
--lo-popup-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--lo-bg: #2c2c2c;
|
||||
--lo-bg-raised: #333333;
|
||||
--lo-bg-field: #1f1f1f;
|
||||
--lo-border: #4a4a4a;
|
||||
--lo-border-soft: #3d3d3d;
|
||||
--lo-text: #e8e8e8;
|
||||
--lo-text-dim: #a0a0a0;
|
||||
--lo-text-disabled: #6a6a6a;
|
||||
--lo-hover: #3d3d3d;
|
||||
--lo-pressed: #474747;
|
||||
--lo-checked: #2f4a6b;
|
||||
--lo-checked-border: #4d78ad;
|
||||
--lo-accent: #4a8fd8;
|
||||
--lo-app-bg: #1b1b1b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--lo-bg);
|
||||
color: var(--lo-text);
|
||||
font: 13px/1.3 var(--lo-font);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---- Menu bar / menus (vcl/source/window/menu.cxx) ---------------------- */
|
||||
|
||||
.vcl-menubar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 26px;
|
||||
padding: 0 4px;
|
||||
background: var(--lo-bg);
|
||||
border-bottom: 1px solid var(--lo-border-soft);
|
||||
}
|
||||
|
||||
.vcl-menubar-item {
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.vcl-menubar-item:hover,
|
||||
.vcl-menubar-item[data-open] {
|
||||
background: var(--lo-hover);
|
||||
}
|
||||
|
||||
.vcl-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 220px;
|
||||
padding: 4px;
|
||||
background: var(--lo-bg-raised);
|
||||
border: 1px solid var(--lo-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--lo-popup-shadow);
|
||||
}
|
||||
|
||||
.vcl-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vcl-menu-item:not(:disabled):hover,
|
||||
.vcl-menu-item[data-open] {
|
||||
background: var(--lo-accent);
|
||||
color: var(--lo-accent-text);
|
||||
}
|
||||
|
||||
.vcl-menu-item:disabled {
|
||||
color: var(--lo-text-disabled);
|
||||
}
|
||||
|
||||
.vcl-menu-item .vcl-menu-check,
|
||||
.vcl-menu-item .vcl-menu-icon {
|
||||
width: 16px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.vcl-menu-item .vcl-menu-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vcl-menu-item .vcl-menu-accel {
|
||||
color: var(--lo-text-dim);
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.vcl-menu-item:not(:disabled):hover .vcl-menu-accel {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.vcl-menu-separator {
|
||||
height: 1px;
|
||||
margin: 4px 6px;
|
||||
background: var(--lo-border-soft);
|
||||
}
|
||||
|
||||
/* ---- ToolBox (vcl/source/window/toolbox.cxx) --------------------------- */
|
||||
|
||||
.vcl-toolbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1px;
|
||||
min-height: 34px;
|
||||
padding: 2px 6px;
|
||||
background: var(--lo-bg);
|
||||
}
|
||||
|
||||
.vcl-toolbox + .vcl-toolbox {
|
||||
border-top: 1px solid var(--lo-border-soft);
|
||||
}
|
||||
|
||||
.vcl-toolbox-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--lo-text);
|
||||
}
|
||||
|
||||
.vcl-toolbox-button:not(:disabled):hover {
|
||||
background: var(--lo-hover);
|
||||
}
|
||||
|
||||
.vcl-toolbox-button:not(:disabled):active {
|
||||
background: var(--lo-pressed);
|
||||
}
|
||||
|
||||
.vcl-toolbox-button[aria-pressed="true"] {
|
||||
background: var(--lo-checked);
|
||||
border-color: var(--lo-checked-border);
|
||||
}
|
||||
|
||||
.vcl-toolbox-button:disabled {
|
||||
color: var(--lo-text-disabled);
|
||||
}
|
||||
|
||||
.vcl-toolbox-separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
margin: 0 5px;
|
||||
background: var(--lo-border);
|
||||
}
|
||||
|
||||
.vcl-split-button {
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.vcl-split-button > .vcl-toolbox-button:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.vcl-split-button > .vcl-toolbox-button:last-child {
|
||||
min-width: 14px;
|
||||
padding: 0 1px;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.vcl-color-swatch {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 4px;
|
||||
margin-top: -3px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ---- Fields (vcl/source/control/combobox.cxx, field.cxx) --------------- */
|
||||
|
||||
.vcl-combobox {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
height: 26px;
|
||||
margin: 0 2px;
|
||||
border: 1px solid var(--lo-border);
|
||||
border-radius: 4px;
|
||||
background: var(--lo-bg-field);
|
||||
}
|
||||
|
||||
.vcl-combobox:focus-within {
|
||||
border-color: var(--lo-accent);
|
||||
outline: 1px solid var(--lo-accent);
|
||||
}
|
||||
|
||||
.vcl-combobox input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
background: none;
|
||||
outline: none;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.vcl-combobox-drop {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--lo-border-soft);
|
||||
background: none;
|
||||
color: var(--lo-text-dim);
|
||||
}
|
||||
|
||||
.vcl-combobox-list {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding: 3px;
|
||||
background: var(--lo-bg-raised);
|
||||
border: 1px solid var(--lo-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--lo-popup-shadow);
|
||||
}
|
||||
|
||||
.vcl-combobox-option {
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vcl-combobox-option:hover,
|
||||
.vcl-combobox-option[aria-selected="true"] {
|
||||
background: var(--lo-accent);
|
||||
color: var(--lo-accent-text);
|
||||
}
|
||||
|
||||
.vcl-field {
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--lo-border);
|
||||
border-radius: 4px;
|
||||
background: var(--lo-bg-field);
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.vcl-field:focus {
|
||||
border-color: var(--lo-accent);
|
||||
outline: 1px solid var(--lo-accent);
|
||||
}
|
||||
|
||||
.vcl-button {
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--lo-border);
|
||||
border-radius: 5px;
|
||||
background: var(--lo-bg-raised);
|
||||
}
|
||||
|
||||
.vcl-button:not(:disabled):hover {
|
||||
background: var(--lo-hover);
|
||||
}
|
||||
|
||||
.vcl-button:disabled {
|
||||
color: var(--lo-text-disabled);
|
||||
}
|
||||
|
||||
.vcl-button[data-default] {
|
||||
background: var(--lo-accent);
|
||||
border-color: var(--lo-accent);
|
||||
color: var(--lo-accent-text);
|
||||
}
|
||||
|
||||
.vcl-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ---- Dialogs (vcl/source/window/dialog.cxx) ----------------------------- */
|
||||
|
||||
.vcl-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.vcl-dialog {
|
||||
position: fixed;
|
||||
z-index: 901;
|
||||
min-width: 320px;
|
||||
max-width: min(640px, calc(100vw - 32px));
|
||||
background: var(--lo-bg);
|
||||
border: 1px solid var(--lo-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--lo-popup-shadow);
|
||||
}
|
||||
|
||||
.vcl-dialog-overlay > .vcl-dialog {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vcl-dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 8px 8px 14px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--lo-border-soft);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.vcl-dialog-title button {
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.vcl-dialog-title button:hover {
|
||||
background: var(--lo-hover);
|
||||
}
|
||||
|
||||
.vcl-dialog-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.vcl-dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
/* ---- Status bar (vcl/source/window/status.cxx) -------------------------- */
|
||||
|
||||
.vcl-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
color: var(--lo-text-dim);
|
||||
background: var(--lo-bg);
|
||||
border-top: 1px solid var(--lo-border-soft);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vcl-statusbar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 10px;
|
||||
border-right: 1px solid var(--lo-border-soft);
|
||||
}
|
||||
|
||||
.vcl-statusbar-item:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.vcl-statusbar-item[data-flex] {
|
||||
flex: 1;
|
||||
}
|
||||