This commit is contained in:
Philip Peterson 2026-09-29 01:25:14 -08:00
commit 64186d8625
109 changed files with 14502 additions and 0 deletions

18
Justfile Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

33
desktop/package.json Normal file
View 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"
}
}

View file

@ -0,0 +1,2 @@
[toolchain]
channel = "1.98.0"

24
desktop/scripts/build.sh Executable file
View 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
View 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
View file

@ -0,0 +1,2 @@
target/
gen/

4542
desktop/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View 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"]

View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,009 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

View 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())
}

View 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");
}

View file

@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
libreoffice_js_lib::run();
}

View 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": "-"
}
}
}

View 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>
);
}

View 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 />;
}

View 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;

View 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]);
}

View 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));
}

View 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)}
/>
);
}

View 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
View 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>,
);

View file

@ -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", "↩");
}

View file

@ -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("~", "");
}

View 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 } }
: {}),
},
};
}

View 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);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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
View 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;

View 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
View 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]) };
}

View 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);
});
});

View 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");
});
}
});

View 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;
}

View file

@ -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;
}

View 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));
}

View 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 };
}

View 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 }));
}

View 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,
};
}

View 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;
}

View 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]);
}

View 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;
}

View 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>
);
});

View 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) : []),
]);
}

View 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;
}
}

View 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)));
}

View 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";
}

View 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;
}

View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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>
`;
}

View 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);
}

View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 });
}

View 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(" ");

View 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>
);
}

View 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";
}

View 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() },
},
};
}

View 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());
}

View 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;
}
}

View 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>
);
}

View 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 };
}

View 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;
}

View 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>
);
}

View 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,
},
};
}

View 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>
);
}

View 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;
}
}

View 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>

View 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>

View 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>

View 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>

View 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,
];

View 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;
}

Some files were not shown because too many files have changed in this diff Show more