* 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;
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;
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 };
}
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();
}
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 });
}
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");
}
insert(text: string): void {
this.replaceRange(this.state.pam, text, "Typing", "typing");
}
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 });
}
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" });
}
insertLineBreak(): void {
this.replaceRange(this.state.pam, "\n", "Insert line break");
}
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" });
}
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");
}
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 });
}
}
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 });
}
copySelection(): SwTextNode[] {
return copyRange(this.state.doc, this.state.pam);
}
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 });
}
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 }],
})),
);
}
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 });
}
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;
}
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" });
}
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" });
}
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(): 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(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;
}
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);
}
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;
}
}