smartrte-react 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +14 -83
- package/dist/adapters/canonicalInlineCommandBridge.d.ts +15 -0
- package/dist/adapters/canonicalInlineCommandBridge.js +269 -0
- package/dist/adapters/canonicalListCommandBridge.d.ts +26 -0
- package/dist/adapters/canonicalListCommandBridge.js +430 -0
- package/dist/adapters/documentFormats.d.ts +26 -0
- package/dist/adapters/documentFormats.js +49 -0
- package/dist/adapters/docxFormat.d.ts +5 -0
- package/dist/adapters/docxFormat.js +272 -0
- package/dist/adapters/domBlockCommandBridge.d.ts +19 -0
- package/dist/adapters/domBlockCommandBridge.js +79 -0
- package/dist/adapters/domChecklistCommandBridge.d.ts +6 -0
- package/dist/adapters/domChecklistCommandBridge.js +33 -0
- package/dist/adapters/domCommandBridge.d.ts +19 -0
- package/dist/adapters/domCommandBridge.js +33 -0
- package/dist/adapters/domInlineAtomCommandBridge.d.ts +14 -0
- package/dist/adapters/domInlineAtomCommandBridge.js +101 -0
- package/dist/adapters/domInlineImageCommandBridge.d.ts +14 -0
- package/dist/adapters/domInlineImageCommandBridge.js +72 -0
- package/dist/adapters/domMoveCommandBridge.d.ts +7 -0
- package/dist/adapters/domMoveCommandBridge.js +54 -0
- package/dist/adapters/domSelectionBridge.d.ts +3 -1
- package/dist/adapters/domSelectionBridge.js +96 -44
- package/dist/adapters/domSmartDocument.js +158 -16
- package/dist/adapters/domTableCommandBridge.d.ts +35 -0
- package/dist/adapters/domTableCommandBridge.js +201 -0
- package/dist/adapters/legacyListShadowComparator.d.ts +26 -0
- package/dist/adapters/legacyListShadowComparator.js +316 -0
- package/dist/adapters/pdfFormat.d.ts +23 -0
- package/dist/adapters/pdfFormat.js +204 -0
- package/dist/adapters/portableDocxAtoms.d.ts +4 -0
- package/dist/adapters/portableDocxAtoms.js +58 -0
- package/dist/adapters/styledDocxFormat.d.ts +8 -0
- package/dist/adapters/styledDocxFormat.js +190 -0
- package/dist/builtInFormatDefinitions.d.ts +5 -0
- package/dist/builtInFormatDefinitions.js +82 -0
- package/dist/canonicalAuthorityFlag.d.ts +32 -0
- package/dist/canonicalAuthorityFlag.js +49 -0
- package/dist/canonicalClipboardRuntime.d.ts +15 -0
- package/dist/canonicalClipboardRuntime.js +76 -0
- package/dist/canonicalEditorRuntime.d.ts +77 -0
- package/dist/canonicalEditorRuntime.js +274 -0
- package/dist/components/CanonicalAuthorityEditor.d.ts +25 -0
- package/dist/components/CanonicalAuthorityEditor.js +518 -0
- package/dist/components/ClassicEditor.d.ts +13 -3
- package/dist/components/ClassicEditor.js +1309 -1159
- package/dist/components/ClassicEditorAuthority.d.ts +38 -0
- package/dist/components/ClassicEditorAuthority.js +49 -0
- package/dist/components/MediaPicker.d.ts +10 -0
- package/dist/components/MediaPicker.js +16 -0
- package/dist/editorController.d.ts +78 -0
- package/dist/editorController.js +298 -0
- package/dist/formatFidelity.d.ts +14 -0
- package/dist/formatFidelity.js +107 -0
- package/dist/formatRuntime.d.ts +50 -0
- package/dist/formatRuntime.js +20 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +10 -0
- package/dist/mediaProvider.d.ts +39 -0
- package/dist/mediaProvider.js +1 -0
- package/dist/pluginRuntime.d.ts +68 -0
- package/dist/pluginRuntime.js +78 -0
- package/dist/standalone/editor.js +722 -495
- package/dist/test-harness/atomShadowComparator.d.ts +14 -0
- package/dist/test-harness/atomShadowComparator.js +63 -0
- package/dist/test-harness/blockShadowComparator.d.ts +30 -0
- package/dist/test-harness/blockShadowComparator.js +140 -0
- package/dist/test-harness/clipboardShadowComparator.d.ts +12 -0
- package/dist/test-harness/clipboardShadowComparator.js +46 -0
- package/dist/test-harness/inlineShadowComparator.d.ts +32 -0
- package/dist/test-harness/inlineShadowComparator.js +152 -0
- package/dist/test-harness/legacyAtomEngine.d.ts +10 -0
- package/dist/test-harness/legacyAtomEngine.js +15 -0
- package/dist/test-harness/legacyBlockEngine.d.ts +31 -0
- package/dist/test-harness/legacyBlockEngine.js +31 -0
- package/dist/test-harness/legacyClipboardEngine.d.ts +14 -0
- package/dist/test-harness/legacyClipboardEngine.js +67 -0
- package/dist/test-harness/legacyInlineEngine.d.ts +19 -0
- package/dist/test-harness/legacyInlineEngine.js +49 -0
- package/dist/test-harness/legacyTableEngine.d.ts +12 -0
- package/dist/test-harness/legacyTableEngine.js +25 -0
- package/dist/test-harness/tableShadowComparator.d.ts +29 -0
- package/dist/test-harness/tableShadowComparator.js +101 -0
- package/dist/theme.d.ts +1 -1
- package/dist/theme.js +28 -6
- package/package.json +1 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type PersistedEditorDocument } from "smartrte-core/foundation";
|
|
3
|
+
import { type CanonicalAuthorityContext } from "../canonicalAuthorityFlag.js";
|
|
4
|
+
import type { SmartEditorChange, SmartEditorHandle } from "../canonicalEditorRuntime.js";
|
|
5
|
+
import type { MediaProvider } from "../mediaProvider.js";
|
|
6
|
+
import type { MediaPickerComponent } from "./MediaPicker.js";
|
|
7
|
+
import { type CanonicalAuthorityEditorProps } from "./CanonicalAuthorityEditor.js";
|
|
8
|
+
import { type ClassicEditorProps as LegacyClassicEditorProps } from "./ClassicEditor.js";
|
|
9
|
+
export type ClassicEditorProps = Omit<LegacyClassicEditorProps, "value" | "onChange"> & {
|
|
10
|
+
/** Initial-only under canonical authority. Use replaceValue for external changes. */
|
|
11
|
+
defaultValue?: string | PersistedEditorDocument;
|
|
12
|
+
/** Legacy HTML value. Ignored after canonical construction. */
|
|
13
|
+
value?: string;
|
|
14
|
+
onChange?: ((change: SmartEditorChange) => void) | ((html: string) => void);
|
|
15
|
+
onHtmlChange?: (html: string) => void;
|
|
16
|
+
canonicalAuthority?: boolean;
|
|
17
|
+
authorityContext?: CanonicalAuthorityContext;
|
|
18
|
+
mediaProvider?: MediaProvider;
|
|
19
|
+
mediaPicker?: MediaPickerComponent;
|
|
20
|
+
onRuntime?: CanonicalAuthorityEditorProps["onRuntime"];
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Uncontrolled by default in canonical mode. The runtime flag supplies a
|
|
24
|
+
* reversible legacy path during rollout; direct prop override has precedence.
|
|
25
|
+
*/
|
|
26
|
+
export declare const ClassicEditor: React.ForwardRefExoticComponent<Omit<LegacyClassicEditorProps, "value" | "onChange"> & {
|
|
27
|
+
/** Initial-only under canonical authority. Use replaceValue for external changes. */
|
|
28
|
+
defaultValue?: string | PersistedEditorDocument;
|
|
29
|
+
/** Legacy HTML value. Ignored after canonical construction. */
|
|
30
|
+
value?: string;
|
|
31
|
+
onChange?: ((change: SmartEditorChange) => void) | ((html: string) => void);
|
|
32
|
+
onHtmlChange?: (html: string) => void;
|
|
33
|
+
canonicalAuthority?: boolean;
|
|
34
|
+
authorityContext?: CanonicalAuthorityContext;
|
|
35
|
+
mediaProvider?: MediaProvider;
|
|
36
|
+
mediaPicker?: MediaPickerComponent;
|
|
37
|
+
onRuntime?: CanonicalAuthorityEditorProps["onRuntime"];
|
|
38
|
+
} & React.RefAttributes<SmartEditorHandle>>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useRef, useSyncExternalStore } from "react";
|
|
3
|
+
import { foundationSchema, parseCanonicalListHtml, serializeCanonicalListHtml } from "smartrte-core/foundation";
|
|
4
|
+
import { canonicalAuthorityFlag } from "../canonicalAuthorityFlag.js";
|
|
5
|
+
import { CanonicalAuthorityEditor } from "./CanonicalAuthorityEditor.js";
|
|
6
|
+
import { LegacyClassicEditor } from "./ClassicEditor.js";
|
|
7
|
+
/**
|
|
8
|
+
* Uncontrolled by default in canonical mode. The runtime flag supplies a
|
|
9
|
+
* reversible legacy path during rollout; direct prop override has precedence.
|
|
10
|
+
*/
|
|
11
|
+
export const ClassicEditor = forwardRef(function ClassicEditor(props, ref) {
|
|
12
|
+
useSyncExternalStore((listener) => canonicalAuthorityFlag.subscribe(listener), () => canonicalAuthorityFlag.getVersion(), () => canonicalAuthorityFlag.getVersion());
|
|
13
|
+
const enabled = canonicalAuthorityFlag.enabled(props.authorityContext, props.canonicalAuthority);
|
|
14
|
+
const latestEnvelope = useRef(typeof props.defaultValue === "object" ? props.defaultValue : undefined);
|
|
15
|
+
const latestHtml = useRef(typeof props.defaultValue === "string" ? props.defaultValue : props.value);
|
|
16
|
+
const runtime = useRef(null);
|
|
17
|
+
if (!enabled) {
|
|
18
|
+
if (runtime.current) {
|
|
19
|
+
// Capture once at the authority boundary, not once per keystroke. This is
|
|
20
|
+
// the ID-preserving rollback envelope; clean HTML remains the legacy view.
|
|
21
|
+
latestEnvelope.current = runtime.current.getValue();
|
|
22
|
+
latestHtml.current = serializeCanonicalListHtml(latestEnvelope.current.document, { clean: true });
|
|
23
|
+
runtime.current = null;
|
|
24
|
+
}
|
|
25
|
+
const { defaultValue, onHtmlChange, canonicalAuthority: _canonical, authorityContext: _context, onRuntime: _runtime, mediaProvider: _provider, mediaPicker: _picker, ...legacy } = props;
|
|
26
|
+
const value = latestEnvelope.current
|
|
27
|
+
// Internal rollback HTML retains stable IDs. External callbacks receive
|
|
28
|
+
// the clean representation below.
|
|
29
|
+
? serializeCanonicalListHtml(latestEnvelope.current.document, { clean: false })
|
|
30
|
+
: latestHtml.current ?? (typeof props.value === "string" ? props.value : typeof defaultValue === "string" ? defaultValue : undefined);
|
|
31
|
+
return _jsx(LegacyClassicEditor, { ...legacy, value: value, onChange: (html) => {
|
|
32
|
+
const document = parseCanonicalListHtml(html);
|
|
33
|
+
latestEnvelope.current = {
|
|
34
|
+
schemaVersion: foundationSchema.version,
|
|
35
|
+
revision: (latestEnvelope.current?.revision || 0) + 1,
|
|
36
|
+
document,
|
|
37
|
+
};
|
|
38
|
+
const cleanHtml = serializeCanonicalListHtml(document, { clean: true });
|
|
39
|
+
latestHtml.current = cleanHtml;
|
|
40
|
+
onHtmlChange?.(cleanHtml);
|
|
41
|
+
const callback = props.onChange;
|
|
42
|
+
callback?.(cleanHtml);
|
|
43
|
+
} });
|
|
44
|
+
}
|
|
45
|
+
const { value, canonicalAuthority: _canonical, authorityContext: _context, table: _table, media: _media, formula: _formula, features: _features, plugins: _plugins, formats: _formats, formatDefinitions: _definitions, mediaManager: _manager, fonts: _fonts, defaultFont: _font, preserveFontFamily: _preserveFont, preserveColors: _preserveColors, preserveDocxStyles: _preserveDocx, theme: _theme, showFontSize: _showFontSize, ...canonical } = props;
|
|
46
|
+
return _jsx(CanonicalAuthorityEditor, { ...canonical, ref: ref, defaultValue: latestEnvelope.current ?? latestHtml.current ?? props.defaultValue ?? value, onChange: (change) => {
|
|
47
|
+
props.onChange?.(change);
|
|
48
|
+
}, onHtmlChange: (html) => { latestHtml.current = html; props.onHtmlChange?.(html); }, onRuntime: (value) => { runtime.current = value; props.onRuntime?.(value); } });
|
|
49
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { MediaKind } from "../mediaProvider.js";
|
|
3
|
+
export interface MediaPickerProps {
|
|
4
|
+
readonly kind: MediaKind;
|
|
5
|
+
readonly onPick: (file: File) => void;
|
|
6
|
+
readonly onCancel: () => void;
|
|
7
|
+
}
|
|
8
|
+
export type MediaPickerComponent = React.ComponentType<MediaPickerProps>;
|
|
9
|
+
/** Small replaceable default; hosts can supply a library/search picker later. */
|
|
10
|
+
export declare const DefaultMediaPicker: MediaPickerComponent;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useRef } from "react";
|
|
3
|
+
const acceptFor = (kind) => kind === "image" ? "image/*" : `${kind}/*`;
|
|
4
|
+
/** Small replaceable default; hosts can supply a library/search picker later. */
|
|
5
|
+
export const DefaultMediaPicker = ({ kind, onPick, onCancel }) => {
|
|
6
|
+
const inputRef = useRef(null);
|
|
7
|
+
return _jsx("div", { role: "dialog", "aria-label": `Choose ${kind}`, style: {
|
|
8
|
+
position: "fixed", inset: 0, zIndex: 90, display: "grid", placeItems: "center",
|
|
9
|
+
background: "var(--srte-modal-backdrop)",
|
|
10
|
+
}, children: _jsxs("div", { style: { background: "var(--srte-modal-bg)", color: "var(--srte-modal-text)", padding: 20, borderRadius: 10, minWidth: 280 }, children: [_jsxs("h3", { style: { marginTop: 0 }, children: ["Insert ", kind] }), _jsx("input", { ref: inputRef, autoFocus: true, type: "file", accept: acceptFor(kind), onChange: (event) => {
|
|
11
|
+
const file = event.currentTarget.files?.[0];
|
|
12
|
+
if (file)
|
|
13
|
+
onPick(file);
|
|
14
|
+
event.currentTarget.value = "";
|
|
15
|
+
} }), _jsx("button", { type: "button", onClick: onCancel, children: "Cancel" })] }) });
|
|
16
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { SmartDocument, SmartEditorState, SmartRtePlugin, SmartSelection, SmartTransaction } from "smartrte-core";
|
|
2
|
+
import { type DomCommandResult } from "./adapters/domCommandBridge.js";
|
|
3
|
+
import { type DomBlockCommand } from "./adapters/domBlockCommandBridge.js";
|
|
4
|
+
import { type DomInlineImageInput } from "./adapters/domInlineImageCommandBridge.js";
|
|
5
|
+
import { type DomFormulaInput } from "./adapters/domInlineAtomCommandBridge.js";
|
|
6
|
+
import { type DomTableCommand } from "./adapters/domTableCommandBridge.js";
|
|
7
|
+
export interface DomEditorControllerSnapshot {
|
|
8
|
+
document: SmartDocument;
|
|
9
|
+
selection: SmartSelection | null;
|
|
10
|
+
html: string;
|
|
11
|
+
}
|
|
12
|
+
export interface DomEditorControllerChange {
|
|
13
|
+
commandId: string;
|
|
14
|
+
snapshot: DomEditorControllerSnapshot;
|
|
15
|
+
}
|
|
16
|
+
export type DomEditorControllerListener = (change: DomEditorControllerChange) => void;
|
|
17
|
+
interface DomHistorySnapshot {
|
|
18
|
+
html: string;
|
|
19
|
+
selection: SmartSelection | null;
|
|
20
|
+
version: number;
|
|
21
|
+
canonical?: {
|
|
22
|
+
forward: SmartTransaction;
|
|
23
|
+
inverse: SmartTransaction;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Owns the canonical boundary between a contenteditable root and SmartDocument.
|
|
28
|
+
*
|
|
29
|
+
* React remains responsible for rendering UI and legacy DOM-only commands while
|
|
30
|
+
* model commands, document snapshots, serialization, and replacement flow
|
|
31
|
+
* through this controller.
|
|
32
|
+
*/
|
|
33
|
+
export declare class DomEditorController {
|
|
34
|
+
private root;
|
|
35
|
+
private plugins;
|
|
36
|
+
private readOnly;
|
|
37
|
+
private readonly listeners;
|
|
38
|
+
private readonly undoStack;
|
|
39
|
+
private readonly redoStack;
|
|
40
|
+
private historyLimit;
|
|
41
|
+
private mutationVersion;
|
|
42
|
+
private canonicalState;
|
|
43
|
+
bindRoot(root: HTMLElement | null): this;
|
|
44
|
+
configure(options: {
|
|
45
|
+
plugins?: readonly SmartRtePlugin[];
|
|
46
|
+
readOnly?: boolean;
|
|
47
|
+
historyLimit?: number;
|
|
48
|
+
}): this;
|
|
49
|
+
snapshot(): DomEditorControllerSnapshot | null;
|
|
50
|
+
getDocument(): SmartDocument;
|
|
51
|
+
getState(): SmartEditorState | null;
|
|
52
|
+
canExecute<Input>(commandId: string, input?: Input): boolean;
|
|
53
|
+
recordHistorySnapshot(html?: string): boolean;
|
|
54
|
+
discardLastHistorySnapshot(expectedHtml?: string): boolean;
|
|
55
|
+
restoreHistory(direction: "undo" | "redo"): DomHistorySnapshot | null;
|
|
56
|
+
historyStatus(): {
|
|
57
|
+
undo: number;
|
|
58
|
+
redo: number;
|
|
59
|
+
canonicalUndo: number;
|
|
60
|
+
canonicalRedo: number;
|
|
61
|
+
};
|
|
62
|
+
execute<Input>(commandId: string, input?: Input): DomCommandResult | null;
|
|
63
|
+
executeBlockCommand(blocks: readonly HTMLElement[], command: DomBlockCommand): HTMLElement[] | null;
|
|
64
|
+
executeChecklistItemCommand(list: HTMLElement, item: HTMLElement, checked?: boolean): HTMLElement | null;
|
|
65
|
+
insertInlineImage(input: DomInlineImageInput): HTMLImageElement | null;
|
|
66
|
+
updateInlineImage(image: HTMLImageElement, input: Omit<DomInlineImageInput, "src">): boolean;
|
|
67
|
+
insertFormula(input: DomFormulaInput): HTMLElement | null;
|
|
68
|
+
deleteInlineAtom(atom: HTMLElement): boolean;
|
|
69
|
+
executeTableCommand(table: HTMLTableElement, command: DomTableCommand): HTMLTableElement | null;
|
|
70
|
+
insertTable(rows: number, columns: number, headerRow?: boolean): HTMLTableElement | null;
|
|
71
|
+
removeTable(table: HTMLTableElement): boolean;
|
|
72
|
+
replaceDocument(document: SmartDocument, selection?: SmartSelection): boolean;
|
|
73
|
+
subscribe(listener: DomEditorControllerListener): () => boolean;
|
|
74
|
+
private emitLocalizedChange;
|
|
75
|
+
private promoteHistoryEntry;
|
|
76
|
+
}
|
|
77
|
+
export declare const createDomEditorController: () => DomEditorController;
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { applyTransaction, invertTransaction } from "smartrte-core";
|
|
2
|
+
import { createSmartEditor } from "smartrte-core";
|
|
3
|
+
import { executeDomCommand } from "./adapters/domCommandBridge.js";
|
|
4
|
+
import { executeDomBlockCommand, } from "./adapters/domBlockCommandBridge.js";
|
|
5
|
+
import { executeDomChecklistItemCommand } from "./adapters/domChecklistCommandBridge.js";
|
|
6
|
+
import { executeDomInlineImageCommand, executeDomInlineImageUpdate, } from "./adapters/domInlineImageCommandBridge.js";
|
|
7
|
+
import { executeDomFormulaInsert, executeDomInlineAtomDelete, } from "./adapters/domInlineAtomCommandBridge.js";
|
|
8
|
+
import { executeDomTableCommand, executeDomTableRemoval, } from "./adapters/domTableCommandBridge.js";
|
|
9
|
+
import { restoreSelectionToDom, selectionFromDom } from "./adapters/domSelectionBridge.js";
|
|
10
|
+
import { serializeSmartDocument, smartDocumentFromEditorRoot, smartDocumentFromHtml, } from "./adapters/domSmartDocument.js";
|
|
11
|
+
/**
|
|
12
|
+
* Owns the canonical boundary between a contenteditable root and SmartDocument.
|
|
13
|
+
*
|
|
14
|
+
* React remains responsible for rendering UI and legacy DOM-only commands while
|
|
15
|
+
* model commands, document snapshots, serialization, and replacement flow
|
|
16
|
+
* through this controller.
|
|
17
|
+
*/
|
|
18
|
+
export class DomEditorController {
|
|
19
|
+
constructor() {
|
|
20
|
+
this.root = null;
|
|
21
|
+
this.plugins = [];
|
|
22
|
+
this.readOnly = false;
|
|
23
|
+
this.listeners = new Set();
|
|
24
|
+
this.undoStack = [];
|
|
25
|
+
this.redoStack = [];
|
|
26
|
+
this.historyLimit = 100;
|
|
27
|
+
this.mutationVersion = 0;
|
|
28
|
+
this.canonicalState = null;
|
|
29
|
+
}
|
|
30
|
+
bindRoot(root) {
|
|
31
|
+
this.root = root;
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
configure(options) {
|
|
35
|
+
if (options.plugins)
|
|
36
|
+
this.plugins = options.plugins;
|
|
37
|
+
if (options.readOnly !== undefined)
|
|
38
|
+
this.readOnly = options.readOnly;
|
|
39
|
+
if (options.historyLimit !== undefined) {
|
|
40
|
+
this.historyLimit = Math.max(1, Math.floor(options.historyLimit));
|
|
41
|
+
}
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
snapshot() {
|
|
45
|
+
if (!this.root)
|
|
46
|
+
return null;
|
|
47
|
+
const { document } = smartDocumentFromEditorRoot(this.root);
|
|
48
|
+
const selection = selectionFromDom(this.root, this.root.ownerDocument.defaultView?.getSelection() || null);
|
|
49
|
+
const snapshot = {
|
|
50
|
+
document,
|
|
51
|
+
selection,
|
|
52
|
+
html: serializeSmartDocument(document),
|
|
53
|
+
};
|
|
54
|
+
this.canonicalState = selection ? { document, selection } : null;
|
|
55
|
+
return snapshot;
|
|
56
|
+
}
|
|
57
|
+
getDocument() {
|
|
58
|
+
return this.snapshot()?.document || { type: "doc", children: [] };
|
|
59
|
+
}
|
|
60
|
+
getState() {
|
|
61
|
+
const snapshot = this.snapshot();
|
|
62
|
+
return snapshot?.selection
|
|
63
|
+
? { document: snapshot.document, selection: snapshot.selection }
|
|
64
|
+
: null;
|
|
65
|
+
}
|
|
66
|
+
canExecute(commandId, input) {
|
|
67
|
+
if (!this.root || this.readOnly)
|
|
68
|
+
return false;
|
|
69
|
+
const state = this.getState();
|
|
70
|
+
if (!state)
|
|
71
|
+
return false;
|
|
72
|
+
return createSmartEditor({
|
|
73
|
+
state,
|
|
74
|
+
plugins: [...this.plugins],
|
|
75
|
+
readOnly: this.readOnly,
|
|
76
|
+
}).canExecute(commandId, input);
|
|
77
|
+
}
|
|
78
|
+
recordHistorySnapshot(html) {
|
|
79
|
+
if (!this.root || this.readOnly)
|
|
80
|
+
return false;
|
|
81
|
+
const snapshot = this.snapshot();
|
|
82
|
+
const entry = {
|
|
83
|
+
html: html ?? this.root.innerHTML,
|
|
84
|
+
selection: snapshot?.selection || null,
|
|
85
|
+
version: this.mutationVersion,
|
|
86
|
+
};
|
|
87
|
+
if (this.undoStack[this.undoStack.length - 1]?.html === entry.html)
|
|
88
|
+
return false;
|
|
89
|
+
this.undoStack.push(entry);
|
|
90
|
+
this.promoteHistoryEntry(entry, snapshot);
|
|
91
|
+
if (this.undoStack.length > this.historyLimit)
|
|
92
|
+
this.undoStack.shift();
|
|
93
|
+
this.redoStack.length = 0;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
discardLastHistorySnapshot(expectedHtml) {
|
|
97
|
+
const latest = this.undoStack[this.undoStack.length - 1];
|
|
98
|
+
if (!latest || expectedHtml !== undefined && latest.html !== expectedHtml)
|
|
99
|
+
return false;
|
|
100
|
+
this.undoStack.pop();
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
restoreHistory(direction) {
|
|
104
|
+
if (!this.root || this.readOnly)
|
|
105
|
+
return null;
|
|
106
|
+
const from = direction === "undo" ? this.undoStack : this.redoStack;
|
|
107
|
+
const to = direction === "undo" ? this.redoStack : this.undoStack;
|
|
108
|
+
let entry;
|
|
109
|
+
while (from.length) {
|
|
110
|
+
const candidate = from.pop();
|
|
111
|
+
if (candidate.html !== this.root.innerHTML) {
|
|
112
|
+
entry = candidate;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!entry)
|
|
117
|
+
return null;
|
|
118
|
+
const current = this.snapshot();
|
|
119
|
+
if (entry.canonical && current) {
|
|
120
|
+
const transaction = direction === "undo"
|
|
121
|
+
? entry.canonical.inverse
|
|
122
|
+
: entry.canonical.forward;
|
|
123
|
+
this.canonicalState = applyTransaction({ document: current.document, selection: current.selection || { type: "all" } }, transaction);
|
|
124
|
+
}
|
|
125
|
+
to.push({
|
|
126
|
+
html: this.root.innerHTML,
|
|
127
|
+
selection: current?.selection || null,
|
|
128
|
+
canonical: entry.canonical,
|
|
129
|
+
version: this.mutationVersion,
|
|
130
|
+
});
|
|
131
|
+
this.root.innerHTML = entry.html;
|
|
132
|
+
this.mutationVersion += 1;
|
|
133
|
+
if (entry.selection)
|
|
134
|
+
restoreSelectionToDom(this.root, entry.selection);
|
|
135
|
+
return entry;
|
|
136
|
+
}
|
|
137
|
+
historyStatus() {
|
|
138
|
+
return {
|
|
139
|
+
undo: this.undoStack.length,
|
|
140
|
+
redo: this.redoStack.length,
|
|
141
|
+
canonicalUndo: this.undoStack.filter((entry) => entry.canonical).length,
|
|
142
|
+
canonicalRedo: this.redoStack.filter((entry) => entry.canonical).length,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
execute(commandId, input) {
|
|
146
|
+
if (!this.root || this.readOnly)
|
|
147
|
+
return null;
|
|
148
|
+
const result = executeDomCommand({
|
|
149
|
+
root: this.root,
|
|
150
|
+
plugins: [...this.plugins],
|
|
151
|
+
commandId,
|
|
152
|
+
input,
|
|
153
|
+
});
|
|
154
|
+
if (!result)
|
|
155
|
+
return null;
|
|
156
|
+
this.emitLocalizedChange(commandId);
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
executeBlockCommand(blocks, command) {
|
|
160
|
+
if (!this.root || this.readOnly || blocks.some((block) => !this.root.contains(block))) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const replacements = executeDomBlockCommand(blocks, command);
|
|
164
|
+
if (!replacements)
|
|
165
|
+
return null;
|
|
166
|
+
this.emitLocalizedChange(command.id);
|
|
167
|
+
return replacements;
|
|
168
|
+
}
|
|
169
|
+
executeChecklistItemCommand(list, item, checked) {
|
|
170
|
+
if (!this.root ||
|
|
171
|
+
this.readOnly ||
|
|
172
|
+
!this.root.contains(list) ||
|
|
173
|
+
!list.contains(item)) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const result = executeDomChecklistItemCommand(list, item, checked);
|
|
177
|
+
if (!result)
|
|
178
|
+
return null;
|
|
179
|
+
this.emitLocalizedChange("checklist.set-checked");
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
insertInlineImage(input) {
|
|
183
|
+
if (!this.root || this.readOnly)
|
|
184
|
+
return null;
|
|
185
|
+
const image = executeDomInlineImageCommand(this.root, input);
|
|
186
|
+
if (!image)
|
|
187
|
+
return null;
|
|
188
|
+
this.emitLocalizedChange("image.insert-inline");
|
|
189
|
+
return image;
|
|
190
|
+
}
|
|
191
|
+
updateInlineImage(image, input) {
|
|
192
|
+
if (!this.root || this.readOnly || !this.root.contains(image))
|
|
193
|
+
return false;
|
|
194
|
+
if (!executeDomInlineImageUpdate(this.root, image, input))
|
|
195
|
+
return false;
|
|
196
|
+
this.emitLocalizedChange("image.update-inline");
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
insertFormula(input) {
|
|
200
|
+
if (!this.root || this.readOnly)
|
|
201
|
+
return null;
|
|
202
|
+
const formula = executeDomFormulaInsert(this.root, input);
|
|
203
|
+
if (!formula)
|
|
204
|
+
return null;
|
|
205
|
+
this.emitLocalizedChange("formula.insert");
|
|
206
|
+
return formula;
|
|
207
|
+
}
|
|
208
|
+
deleteInlineAtom(atom) {
|
|
209
|
+
if (!this.root || this.readOnly || !this.root.contains(atom))
|
|
210
|
+
return false;
|
|
211
|
+
const commandId = atom.hasAttribute("data-formula") ? "formula.delete" : "image.delete-inline";
|
|
212
|
+
if (!executeDomInlineAtomDelete(this.root, atom))
|
|
213
|
+
return false;
|
|
214
|
+
this.emitLocalizedChange(commandId);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
executeTableCommand(table, command) {
|
|
218
|
+
if (!this.root || this.readOnly || !this.root.contains(table))
|
|
219
|
+
return null;
|
|
220
|
+
const replacement = executeDomTableCommand(table, command);
|
|
221
|
+
if (!replacement)
|
|
222
|
+
return null;
|
|
223
|
+
this.emitLocalizedChange(command.id);
|
|
224
|
+
return replacement;
|
|
225
|
+
}
|
|
226
|
+
insertTable(rows, columns, headerRow = false) {
|
|
227
|
+
if (!this.root || this.readOnly)
|
|
228
|
+
return null;
|
|
229
|
+
const snapshot = this.snapshot();
|
|
230
|
+
if (!snapshot?.selection || snapshot.selection.type !== "text")
|
|
231
|
+
return null;
|
|
232
|
+
const topLevelIndex = snapshot.selection.anchor.path[0];
|
|
233
|
+
if (!Number.isInteger(topLevelIndex))
|
|
234
|
+
return null;
|
|
235
|
+
const path = [Math.min(topLevelIndex + 1, snapshot.document.children.length)];
|
|
236
|
+
const result = this.execute("table.insert", { path, rows, columns, headerRow });
|
|
237
|
+
if (!result)
|
|
238
|
+
return null;
|
|
239
|
+
const candidate = this.root.children[path[0]];
|
|
240
|
+
if (candidate instanceof HTMLTableElement)
|
|
241
|
+
return candidate;
|
|
242
|
+
return candidate?.querySelector("table") || null;
|
|
243
|
+
}
|
|
244
|
+
removeTable(table) {
|
|
245
|
+
if (!this.root || this.readOnly || !this.root.contains(table))
|
|
246
|
+
return false;
|
|
247
|
+
if (!executeDomTableRemoval(table))
|
|
248
|
+
return false;
|
|
249
|
+
this.emitLocalizedChange("table.remove");
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
replaceDocument(document, selection) {
|
|
253
|
+
if (!this.root || this.readOnly)
|
|
254
|
+
return false;
|
|
255
|
+
this.root.innerHTML = serializeSmartDocument(document);
|
|
256
|
+
if (selection)
|
|
257
|
+
restoreSelectionToDom(this.root, selection);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
subscribe(listener) {
|
|
261
|
+
this.listeners.add(listener);
|
|
262
|
+
return () => this.listeners.delete(listener);
|
|
263
|
+
}
|
|
264
|
+
emitLocalizedChange(commandId) {
|
|
265
|
+
const snapshot = this.snapshot();
|
|
266
|
+
if (snapshot) {
|
|
267
|
+
const latest = this.undoStack[this.undoStack.length - 1];
|
|
268
|
+
if (latest?.version === this.mutationVersion) {
|
|
269
|
+
this.promoteHistoryEntry(latest, snapshot, commandId);
|
|
270
|
+
}
|
|
271
|
+
this.mutationVersion += 1;
|
|
272
|
+
this.listeners.forEach((listener) => listener({ commandId, snapshot }));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
promoteHistoryEntry(entry, after, commandId = "dom.canonical") {
|
|
276
|
+
if (!this.root || !after || entry.canonical || entry.html === this.root.innerHTML)
|
|
277
|
+
return;
|
|
278
|
+
const beforeDocument = smartDocumentFromHtml(entry.html, this.root.ownerDocument);
|
|
279
|
+
if (JSON.stringify(beforeDocument) === JSON.stringify(after.document))
|
|
280
|
+
return;
|
|
281
|
+
const beforeSelection = entry.selection || { type: "all" };
|
|
282
|
+
const afterSelection = after.selection || { type: "all" };
|
|
283
|
+
const forward = {
|
|
284
|
+
id: commandId,
|
|
285
|
+
source: "user",
|
|
286
|
+
operations: [{ type: "replaceNode", path: [], node: after.document }],
|
|
287
|
+
selectionBefore: beforeSelection,
|
|
288
|
+
selectionAfter: afterSelection,
|
|
289
|
+
addToHistory: true,
|
|
290
|
+
timestamp: Date.now(),
|
|
291
|
+
};
|
|
292
|
+
entry.canonical = {
|
|
293
|
+
forward,
|
|
294
|
+
inverse: invertTransaction({ document: beforeDocument, selection: beforeSelection }, forward),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
export const createDomEditorController = () => new DomEditorController();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type FidelityFormat = "html" | "markdown" | "docx" | "pdf";
|
|
2
|
+
export type FidelityLevel = "full" | "semantic" | "lossy" | "unsupported";
|
|
3
|
+
export type FidelityFeature = "inline-marks" | "colors-fonts-sizes" | "headings-alignment" | "blockquote-code" | "lists" | "checklists" | "tables" | "links" | "images-media" | "formulas" | "special-characters";
|
|
4
|
+
export interface FormatFidelityCapability {
|
|
5
|
+
level: FidelityLevel;
|
|
6
|
+
note: string;
|
|
7
|
+
}
|
|
8
|
+
export interface FeatureFidelityContract {
|
|
9
|
+
feature: FidelityFeature;
|
|
10
|
+
formats: Record<FidelityFormat, FormatFidelityCapability>;
|
|
11
|
+
}
|
|
12
|
+
/** Public, test-enforced compatibility contract for built-in document formats. */
|
|
13
|
+
export declare const builtInFormatFidelity: readonly FeatureFidelityContract[];
|
|
14
|
+
export declare const getFormatFidelity: (feature: FidelityFeature, format: FidelityFormat) => FormatFidelityCapability;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const capability = (level, note) => ({
|
|
2
|
+
level,
|
|
3
|
+
note,
|
|
4
|
+
});
|
|
5
|
+
/** Public, test-enforced compatibility contract for built-in document formats. */
|
|
6
|
+
export const builtInFormatFidelity = [
|
|
7
|
+
{
|
|
8
|
+
feature: "inline-marks",
|
|
9
|
+
formats: {
|
|
10
|
+
html: capability("full", "Canonical marks round-trip."),
|
|
11
|
+
markdown: capability("semantic", "Bold, italic, strike, code, and links round-trip; underline is lossy."),
|
|
12
|
+
docx: capability("semantic", "Supported Word run properties round-trip through the canonical importer."),
|
|
13
|
+
pdf: capability("lossy", "Visual emphasis is inferred from extracted font metadata."),
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
feature: "colors-fonts-sizes",
|
|
18
|
+
formats: {
|
|
19
|
+
html: capability("full", "Inline style marks round-trip."),
|
|
20
|
+
markdown: capability("unsupported", "Portable Markdown has no standard representation."),
|
|
21
|
+
docx: capability("semantic", "Colors and sizes are supported; font-family fidelity depends on importer styles."),
|
|
22
|
+
pdf: capability("lossy", "Font size/style are inferred; colors are extraction-dependent."),
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
feature: "headings-alignment",
|
|
27
|
+
formats: {
|
|
28
|
+
html: capability("full", "Levels, alignment, and indentation round-trip."),
|
|
29
|
+
markdown: capability("lossy", "Heading levels round-trip; alignment and indentation do not."),
|
|
30
|
+
docx: capability("semantic", "Heading styles, alignment, and indentation are emitted."),
|
|
31
|
+
pdf: capability("lossy", "Levels and alignment are inferred geometrically."),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
feature: "blockquote-code",
|
|
36
|
+
formats: {
|
|
37
|
+
html: capability("full", "Canonical block structure round-trips."),
|
|
38
|
+
markdown: capability("semantic", "Portable blockquote and code syntax round-trip."),
|
|
39
|
+
docx: capability("lossy", "Structure is represented with paragraph styling."),
|
|
40
|
+
pdf: capability("lossy", "Export is visual and import reconstruction is heuristic."),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
feature: "lists",
|
|
45
|
+
formats: {
|
|
46
|
+
html: capability("full", "Nested list structure and styles round-trip."),
|
|
47
|
+
markdown: capability("semantic", "Nested ordered and unordered structure round-trips; custom markers are lossy."),
|
|
48
|
+
docx: capability("semantic", "Nested lists export with native Word numbering definitions and level metadata."),
|
|
49
|
+
pdf: capability("lossy", "List structure is inferred from extracted markers."),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
feature: "checklists",
|
|
54
|
+
formats: {
|
|
55
|
+
html: capability("full", "Checked state uses canonical data attributes."),
|
|
56
|
+
markdown: capability("semantic", "GFM task-list state round-trips."),
|
|
57
|
+
docx: capability("lossy", "Checklist semantics currently degrade to visible list content."),
|
|
58
|
+
pdf: capability("lossy", "Checklist semantics are visual only."),
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
feature: "tables",
|
|
63
|
+
formats: {
|
|
64
|
+
html: capability("full", "Spans, headers, colors, borders, and dimensions round-trip."),
|
|
65
|
+
markdown: capability("lossy", "GFM tables preserve text but not spans, dimensions, colors, or borders."),
|
|
66
|
+
docx: capability("semantic", "Table structure and column spans export; advanced layout remains partial."),
|
|
67
|
+
pdf: capability("lossy", "Export is visual and import uses column-position heuristics."),
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
feature: "links",
|
|
72
|
+
formats: {
|
|
73
|
+
html: capability("full", "Safe href and target semantics round-trip."),
|
|
74
|
+
markdown: capability("semantic", "Href and label round-trip; target metadata is lossy."),
|
|
75
|
+
docx: capability("semantic", "Links export as native external Word hyperlink relationships; target behavior is advisory."),
|
|
76
|
+
pdf: capability("lossy", "Links are visual text in the current print/export path."),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
feature: "images-media",
|
|
81
|
+
formats: {
|
|
82
|
+
html: capability("semantic", "Images, audio, and video round-trip; host-only metadata may be lossy."),
|
|
83
|
+
markdown: capability("lossy", "Inline images round-trip; audio and video are unsupported."),
|
|
84
|
+
docx: capability("semantic", "Data-URL PNG, JPEG, and GIF images embed natively; remote and unsupported sources retain portable markers."),
|
|
85
|
+
pdf: capability("lossy", "Export is visual; semantic media import is unsupported."),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
feature: "formulas",
|
|
90
|
+
formats: {
|
|
91
|
+
html: capability("full", "Formula source is stored canonically."),
|
|
92
|
+
markdown: capability("semantic", "Dollar-delimited formula source round-trips."),
|
|
93
|
+
docx: capability("semantic", "Formulas emit native OMML linear math and retain a hidden portable source marker for canonical round-trip."),
|
|
94
|
+
pdf: capability("lossy", "Rendered output is visual; source cannot be reconstructed reliably."),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
feature: "special-characters",
|
|
99
|
+
formats: {
|
|
100
|
+
html: capability("full", "Unicode text round-trips."),
|
|
101
|
+
markdown: capability("full", "Unicode text round-trips."),
|
|
102
|
+
docx: capability("full", "Unicode text is emitted as Word text."),
|
|
103
|
+
pdf: capability("semantic", "Depends on font embedding and extractor Unicode maps."),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
export const getFormatFidelity = (feature, format) => builtInFormatFidelity.find((entry) => entry.feature === feature).formats[format];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { SmartDocument } from "smartrte-core";
|
|
2
|
+
export type BuiltInDocumentFormat = "html" | "markdown" | "docx" | "pdf";
|
|
3
|
+
export type EditorDocumentFormat = BuiltInDocumentFormat | (string & {});
|
|
4
|
+
export type EditorFormatConfig = Partial<Record<EditorDocumentFormat, boolean>>;
|
|
5
|
+
export interface EditorFormatImportContext {
|
|
6
|
+
ownerDocument: Document;
|
|
7
|
+
}
|
|
8
|
+
export interface EditorFormatImportResult {
|
|
9
|
+
document: SmartDocument;
|
|
10
|
+
layoutHtml?: string;
|
|
11
|
+
preserveColors?: boolean;
|
|
12
|
+
preserveDocumentLayout?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export type EditorFormatExportResult = {
|
|
15
|
+
kind: "text";
|
|
16
|
+
filename: string;
|
|
17
|
+
mediaType: string;
|
|
18
|
+
content: string;
|
|
19
|
+
} | {
|
|
20
|
+
kind: "blob";
|
|
21
|
+
filename: string;
|
|
22
|
+
content: Blob;
|
|
23
|
+
} | {
|
|
24
|
+
kind: "handled";
|
|
25
|
+
};
|
|
26
|
+
export interface EditorFormatExportContext {
|
|
27
|
+
ownerDocument: Document;
|
|
28
|
+
hostWindow: Window;
|
|
29
|
+
}
|
|
30
|
+
export interface EditorFormatDefinition {
|
|
31
|
+
id: EditorDocumentFormat;
|
|
32
|
+
label: string;
|
|
33
|
+
extension: string;
|
|
34
|
+
accept?: string;
|
|
35
|
+
canImport: boolean;
|
|
36
|
+
canExport: boolean;
|
|
37
|
+
confirmImportWhenNotEmpty?: boolean;
|
|
38
|
+
importFile?: (file: File, context: EditorFormatImportContext) => Promise<EditorFormatImportResult>;
|
|
39
|
+
exportDocument?: (document: SmartDocument, context: EditorFormatExportContext) => EditorFormatExportResult | Promise<EditorFormatExportResult>;
|
|
40
|
+
}
|
|
41
|
+
export interface EditorFormatRuntime {
|
|
42
|
+
formats: readonly EditorFormatDefinition[];
|
|
43
|
+
imports: readonly EditorFormatDefinition[];
|
|
44
|
+
exports: readonly EditorFormatDefinition[];
|
|
45
|
+
has: (format: string) => boolean;
|
|
46
|
+
get: (format: string) => EditorFormatDefinition | undefined;
|
|
47
|
+
canImport: (format: string) => boolean;
|
|
48
|
+
canExport: (format: string) => boolean;
|
|
49
|
+
}
|
|
50
|
+
export declare const createEditorFormatRuntime: (config?: EditorFormatConfig, replacements?: readonly EditorFormatDefinition[]) => EditorFormatRuntime;
|