@lotics/docx 0.1.0

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.
Files changed (107) hide show
  1. package/package.json +40 -0
  2. package/src/fixtures/.gitkeep +0 -0
  3. package/src/fixtures/lotics_generated_contract.docx +0 -0
  4. package/src/fonts/bundled.ts +123 -0
  5. package/src/fonts/registry.test.ts +233 -0
  6. package/src/fonts/registry.ts +219 -0
  7. package/src/fonts/types.ts +83 -0
  8. package/src/index.ts +16 -0
  9. package/src/layout/engine.test.ts +430 -0
  10. package/src/layout/engine.ts +566 -0
  11. package/src/layout/page_geometry.ts +43 -0
  12. package/src/layout/types.ts +159 -0
  13. package/src/load.test.ts +144 -0
  14. package/src/load.ts +142 -0
  15. package/src/model/default_numbering.ts +101 -0
  16. package/src/model/default_styles.ts +201 -0
  17. package/src/model/numbering_table.ts +52 -0
  18. package/src/model/properties.ts +328 -0
  19. package/src/model/sections.ts +94 -0
  20. package/src/model/style_resolution.test.ts +219 -0
  21. package/src/model/style_resolution.ts +113 -0
  22. package/src/model/style_table.ts +22 -0
  23. package/src/model/theme.ts +156 -0
  24. package/src/model/types.ts +55 -0
  25. package/src/parse/drawing.ts +157 -0
  26. package/src/parse/font_table.ts +132 -0
  27. package/src/parse/footnotes.ts +60 -0
  28. package/src/parse/header_footer.test.ts +264 -0
  29. package/src/parse/header_footer.ts +66 -0
  30. package/src/parse/numbering.ts +187 -0
  31. package/src/parse/parser.ts +184 -0
  32. package/src/parse/relationships.ts +83 -0
  33. package/src/parse/sections.test.ts +192 -0
  34. package/src/parse/sections.ts +182 -0
  35. package/src/parse/styles.ts +149 -0
  36. package/src/parse/theme.test.ts +86 -0
  37. package/src/parse/theme.ts +112 -0
  38. package/src/pm/bubble_menu.ts +117 -0
  39. package/src/pm/commands.test.ts +185 -0
  40. package/src/pm/commands.ts +697 -0
  41. package/src/pm/commands_insert.test.ts +183 -0
  42. package/src/pm/docx_to_pm.test.ts +330 -0
  43. package/src/pm/docx_to_pm.ts +643 -0
  44. package/src/pm/drag_handle.ts +166 -0
  45. package/src/pm/format_painter.test.ts +91 -0
  46. package/src/pm/format_painter.ts +109 -0
  47. package/src/pm/header_footer_doc.ts +24 -0
  48. package/src/pm/hyperlinks.test.ts +234 -0
  49. package/src/pm/image_registry.test.ts +81 -0
  50. package/src/pm/image_registry.ts +100 -0
  51. package/src/pm/images.test.ts +257 -0
  52. package/src/pm/link_popover.ts +159 -0
  53. package/src/pm/mark_commands.ts +60 -0
  54. package/src/pm/marks.ts +169 -0
  55. package/src/pm/nodes.ts +258 -0
  56. package/src/pm/numbering.test.ts +210 -0
  57. package/src/pm/numbering_plugin.test.ts +71 -0
  58. package/src/pm/numbering_plugin.ts +96 -0
  59. package/src/pm/outline.ts +41 -0
  60. package/src/pm/page_break.test.ts +80 -0
  61. package/src/pm/page_layout.test.ts +87 -0
  62. package/src/pm/pagination_plugin.test.ts +155 -0
  63. package/src/pm/pagination_plugin.ts +590 -0
  64. package/src/pm/phase5.test.ts +271 -0
  65. package/src/pm/phase6.test.ts +215 -0
  66. package/src/pm/placeholder_plugin.ts +24 -0
  67. package/src/pm/plugins.ts +91 -0
  68. package/src/pm/pm_to_docx.ts +0 -0
  69. package/src/pm/roundtrip.test.ts +332 -0
  70. package/src/pm/schema.test.ts +188 -0
  71. package/src/pm/schema.ts +79 -0
  72. package/src/pm/search.ts +46 -0
  73. package/src/pm/table_attrs.ts +48 -0
  74. package/src/pm/table_borders.test.ts +117 -0
  75. package/src/pm/table_borders.ts +130 -0
  76. package/src/pm/table_convert.test.ts +221 -0
  77. package/src/pm/table_convert.ts +541 -0
  78. package/src/pm/table_decorations.ts +132 -0
  79. package/src/pm/table_handles.ts +163 -0
  80. package/src/pm/template_marker.ts +47 -0
  81. package/src/pm/template_plugin.ts +65 -0
  82. package/src/pm/templates.test.ts +162 -0
  83. package/src/render/clipboard.test.ts +115 -0
  84. package/src/render/clipboard.ts +200 -0
  85. package/src/render/editable_view.test.ts +173 -0
  86. package/src/render/footnotes_view.ts +94 -0
  87. package/src/render/header_footer_view.ts +95 -0
  88. package/src/render/link_mark_view.ts +26 -0
  89. package/src/render/media_resolver.ts +61 -0
  90. package/src/render/node_views.ts +296 -0
  91. package/src/render/numbering_counter.ts +149 -0
  92. package/src/render/page_chrome.test.ts +262 -0
  93. package/src/render/page_chrome.ts +343 -0
  94. package/src/render/page_styles.ts +234 -0
  95. package/src/render/paragraph_view.test.ts +162 -0
  96. package/src/render/paragraph_view.ts +141 -0
  97. package/src/render/ruler.ts +110 -0
  98. package/src/render/style_registry.ts +33 -0
  99. package/src/render/table_dom.test.ts +171 -0
  100. package/src/render/table_dom.ts +288 -0
  101. package/src/render/units.ts +18 -0
  102. package/src/render/view.test.ts +165 -0
  103. package/src/render/view.ts +607 -0
  104. package/src/roundtrip.test.ts +179 -0
  105. package/src/serialize/default_parts.ts +128 -0
  106. package/src/serialize/header_footer_pm.ts +82 -0
  107. package/src/serialize/serializer.ts +114 -0
@@ -0,0 +1,163 @@
1
+ import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
2
+ import {
3
+ addColumnAfter,
4
+ addRowAfter,
5
+ TableMap,
6
+ } from "prosemirror-tables";
7
+ import type { EditorView } from "prosemirror-view";
8
+ import type { Node as PMNode } from "prosemirror-model";
9
+ import { registerGlobalStyle } from "../render/style_registry";
10
+
11
+ const tableHandlesKey = new PluginKey("docx-table-handles");
12
+
13
+ const HANDLES_CSS = `
14
+ .docx-table-add-handle {
15
+ position: absolute;
16
+ display: none;
17
+ background: #1a73e8;
18
+ color: #ffffff;
19
+ border: none;
20
+ border-radius: 999px;
21
+ width: 18px;
22
+ height: 18px;
23
+ align-items: center;
24
+ justify-content: center;
25
+ font-size: 14px;
26
+ font-weight: 600;
27
+ cursor: pointer;
28
+ z-index: 30;
29
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
30
+ line-height: 1;
31
+ }
32
+ .docx-table-add-handle[data-visible="true"] { display: inline-flex; }
33
+ .docx-table-add-handle:hover { background: #185abc; }
34
+ `;
35
+
36
+ function installStyle(): () => void {
37
+ return registerGlobalStyle("docx-table-handles-style", HANDLES_CSS);
38
+ }
39
+
40
+ function findTableUnderPoint(
41
+ view: EditorView,
42
+ clientX: number,
43
+ clientY: number,
44
+ ): { table: PMNode; pos: number; dom: HTMLElement } | null {
45
+ const target = document.elementFromPoint(clientX, clientY);
46
+ if (!(target instanceof HTMLElement)) return null;
47
+ // closest("table") returns the *innermost* enclosing <table>, which
48
+ // matches what the user is pointing at when nested tables exist.
49
+ const tableEl = target.closest("table") as HTMLElement | null;
50
+ if (!tableEl) return null;
51
+ if (!view.dom.contains(tableEl)) return null;
52
+ const posAtDom = view.posAtDOM(tableEl, 0);
53
+ const $pos = view.state.doc.resolve(posAtDom);
54
+ // Walk from the *deepest* depth upward and return the first (innermost)
55
+ // table node — matches the innermost <table> element resolved above.
56
+ for (let depth = $pos.depth; depth >= 0; depth--) {
57
+ const node = $pos.node(depth);
58
+ if (node.type.name === "table") {
59
+ return { table: node, pos: $pos.before(depth), dom: tableEl };
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function selectIntoCell(view: EditorView, cellAbsPos: number) {
66
+ const tr = view.state.tr.setSelection(
67
+ TextSelection.near(view.state.doc.resolve(cellAbsPos + 2)),
68
+ );
69
+ view.dispatch(tr);
70
+ }
71
+
72
+ export function tableHandlesPlugin(): Plugin {
73
+ return new Plugin({
74
+ key: tableHandlesKey,
75
+ view(view) {
76
+ const releaseStyle = installStyle();
77
+
78
+ const addRowBtn = document.createElement("button");
79
+ addRowBtn.className = "docx-table-add-handle";
80
+ addRowBtn.setAttribute("data-docx-add-row", "true");
81
+ addRowBtn.setAttribute("title", "Add row");
82
+ addRowBtn.textContent = "+";
83
+
84
+ const addColBtn = document.createElement("button");
85
+ addColBtn.className = "docx-table-add-handle";
86
+ addColBtn.setAttribute("data-docx-add-col", "true");
87
+ addColBtn.setAttribute("title", "Add column");
88
+ addColBtn.textContent = "+";
89
+
90
+ document.body.appendChild(addRowBtn);
91
+ document.body.appendChild(addColBtn);
92
+
93
+ let activeTable: { pos: number; dom: HTMLElement } | null = null;
94
+
95
+ const hide = () => {
96
+ addRowBtn.setAttribute("data-visible", "false");
97
+ addColBtn.setAttribute("data-visible", "false");
98
+ activeTable = null;
99
+ };
100
+
101
+ const onMove = (e: MouseEvent) => {
102
+ const found = findTableUnderPoint(view, e.clientX, e.clientY);
103
+ if (!found) {
104
+ hide();
105
+ return;
106
+ }
107
+ activeTable = { pos: found.pos, dom: found.dom };
108
+ const r = found.dom.getBoundingClientRect();
109
+ addColBtn.style.left = `${r.right + window.scrollX - 9}px`;
110
+ addColBtn.style.top = `${r.top + window.scrollY + r.height / 2 - 9}px`;
111
+ addColBtn.setAttribute("data-visible", "true");
112
+ addRowBtn.style.left = `${r.left + window.scrollX + r.width / 2 - 9}px`;
113
+ addRowBtn.style.top = `${r.bottom + window.scrollY - 9}px`;
114
+ addRowBtn.setAttribute("data-visible", "true");
115
+ };
116
+
117
+ const onLeave = (e: MouseEvent) => {
118
+ const target = e.relatedTarget as HTMLElement | null;
119
+ if (target === addRowBtn || target === addColBtn) return;
120
+ if (!target || !view.dom.contains(target)) hide();
121
+ };
122
+
123
+ view.dom.addEventListener("mousemove", onMove);
124
+ view.dom.addEventListener("mouseleave", onLeave);
125
+
126
+ addRowBtn.addEventListener("mousedown", (e) => {
127
+ e.preventDefault();
128
+ if (!activeTable) return;
129
+ const tableNode = view.state.doc.nodeAt(activeTable.pos);
130
+ if (!tableNode) return;
131
+ const map = TableMap.get(tableNode);
132
+ if (map.height === 0) return;
133
+ const lastCellRel = map.map[map.map.length - 1];
134
+ selectIntoCell(view, activeTable.pos + 1 + lastCellRel);
135
+ addRowAfter(view.state, view.dispatch);
136
+ view.focus();
137
+ });
138
+
139
+ addColBtn.addEventListener("mousedown", (e) => {
140
+ e.preventDefault();
141
+ if (!activeTable) return;
142
+ const tableNode = view.state.doc.nodeAt(activeTable.pos);
143
+ if (!tableNode) return;
144
+ const map = TableMap.get(tableNode);
145
+ if (map.width === 0 || map.height === 0) return;
146
+ const lastColCellRel = map.map[map.width - 1];
147
+ selectIntoCell(view, activeTable.pos + 1 + lastColCellRel);
148
+ addColumnAfter(view.state, view.dispatch);
149
+ view.focus();
150
+ });
151
+
152
+ return {
153
+ destroy() {
154
+ view.dom.removeEventListener("mousemove", onMove);
155
+ view.dom.removeEventListener("mouseleave", onLeave);
156
+ addRowBtn.remove();
157
+ addColBtn.remove();
158
+ releaseStyle();
159
+ },
160
+ };
161
+ },
162
+ });
163
+ }
@@ -0,0 +1,47 @@
1
+ // Matches: {{var}}, {{FOR x IN y}}, {{END-FOR y}}, {{IF x}}, {{END-IF x}}, {{$item.field}}
2
+ export const TEMPLATE_MARKER_RE =
3
+ /\{\{(?:FOR\s+\w+\s+IN\s+|END-FOR\s+|IF\s+|END-IF\s+)?[\w.$ ]+\}\}/g;
4
+
5
+ export type TemplateMarkerKind =
6
+ | "variable"
7
+ | "for"
8
+ | "end-for"
9
+ | "if"
10
+ | "end-if";
11
+
12
+ export type TemplateMarker = {
13
+ raw: string;
14
+ kind: TemplateMarkerKind;
15
+ varName: string;
16
+ };
17
+
18
+ export function parseTemplateMarker(raw: string): TemplateMarker {
19
+ const inner = raw.slice(2, -2).trim();
20
+ if (inner.startsWith("FOR ")) {
21
+ const parts = inner.split(/\s+/);
22
+ return { raw, kind: "for", varName: parts[parts.length - 1] };
23
+ }
24
+ if (inner.startsWith("END-FOR ")) {
25
+ return { raw, kind: "end-for", varName: inner.slice(8).trim() };
26
+ }
27
+ if (inner.startsWith("IF ")) {
28
+ return { raw, kind: "if", varName: inner.slice(3).trim() };
29
+ }
30
+ if (inner.startsWith("END-IF ")) {
31
+ return { raw, kind: "end-if", varName: inner.slice(7).trim() };
32
+ }
33
+ return { raw, kind: "variable", varName: inner };
34
+ }
35
+
36
+ export function findTemplateMarkers(text: string): Array<{
37
+ index: number;
38
+ marker: TemplateMarker;
39
+ }> {
40
+ const out: Array<{ index: number; marker: TemplateMarker }> = [];
41
+ TEMPLATE_MARKER_RE.lastIndex = 0;
42
+ let m: RegExpExecArray | null;
43
+ while ((m = TEMPLATE_MARKER_RE.exec(text)) !== null) {
44
+ out.push({ index: m.index, marker: parseTemplateMarker(m[0]) });
45
+ }
46
+ return out;
47
+ }
@@ -0,0 +1,65 @@
1
+ import { Plugin, PluginKey } from "prosemirror-state";
2
+ import type { EditorState } from "prosemirror-state";
3
+ import { Decoration, DecorationSet } from "prosemirror-view";
4
+ import {
5
+ findTemplateMarkers,
6
+ type TemplateMarkerKind,
7
+ } from "./template_marker";
8
+
9
+ const templatePluginKey = new PluginKey<DecorationSet>(
10
+ "docx-template-markers",
11
+ );
12
+
13
+ const COLOR_BY_KIND: Record<TemplateMarkerKind, { bg: string; border: string }> = {
14
+ variable: { bg: "rgba(59,130,246,0.15)", border: "#3b82f6" },
15
+ for: { bg: "rgba(34,197,94,0.15)", border: "#22c55e" },
16
+ "end-for": { bg: "rgba(34,197,94,0.10)", border: "#22c55e" },
17
+ if: { bg: "rgba(249,115,22,0.15)", border: "#f97316" },
18
+ "end-if": { bg: "rgba(249,115,22,0.10)", border: "#f97316" },
19
+ };
20
+
21
+ function buildDecorations(state: EditorState): DecorationSet {
22
+ const decorations: Decoration[] = [];
23
+ state.doc.descendants((node, pos) => {
24
+ if (!node.isText) return true;
25
+ const text = node.text ?? "";
26
+ const matches = findTemplateMarkers(text);
27
+ for (const m of matches) {
28
+ const from = pos + m.index;
29
+ const to = from + m.marker.raw.length;
30
+ const color = COLOR_BY_KIND[m.marker.kind];
31
+ decorations.push(
32
+ Decoration.inline(
33
+ from,
34
+ to,
35
+ {
36
+ class: "docx-template-marker",
37
+ style: `background-color: ${color.bg}; border-bottom: 2px solid ${color.border}; border-radius: 2px; padding: 1px 2px;`,
38
+ "data-template-kind": m.marker.kind,
39
+ "data-template-name": m.marker.varName,
40
+ },
41
+ ),
42
+ );
43
+ }
44
+ return false;
45
+ });
46
+ return DecorationSet.create(state.doc, decorations);
47
+ }
48
+
49
+ export function templateMarkerPlugin(): Plugin<DecorationSet> {
50
+ return new Plugin<DecorationSet>({
51
+ key: templatePluginKey,
52
+ state: {
53
+ init: (_config, state) => buildDecorations(state),
54
+ apply: (tr, value, _oldState, newState) => {
55
+ if (!tr.docChanged) return value.map(tr.mapping, tr.doc);
56
+ return buildDecorations(newState);
57
+ },
58
+ },
59
+ props: {
60
+ decorations(state) {
61
+ return templatePluginKey.getState(state) ?? null;
62
+ },
63
+ },
64
+ });
65
+ }
@@ -0,0 +1,162 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { TextSelection } from "prosemirror-state";
4
+ import {
5
+ findTemplateMarkers,
6
+ parseTemplateMarker,
7
+ TEMPLATE_MARKER_RE,
8
+ } from "./template_marker";
9
+ import {
10
+ insertVariable,
11
+ insertLoopVariable,
12
+ wrapSelectionInLoop,
13
+ wrapSelectionInConditional,
14
+ } from "./commands";
15
+ import { docxSchema } from "./schema";
16
+ import { createEditableView } from "../render/view";
17
+ import { buildFontRegistry } from "../fonts/registry";
18
+ import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
19
+ import type { Section } from "../model/sections";
20
+
21
+ const fontRegistry = buildFontRegistry({
22
+ embeddedFonts: [],
23
+ workspaceFonts: [],
24
+ });
25
+
26
+ const defaultSections: Section[] = [
27
+ {
28
+ properties: { ...DEFAULT_SECTION_PROPERTIES },
29
+ blockStartIndex: 0,
30
+ blockEndIndex: 0,
31
+ },
32
+ ];
33
+
34
+ let host: HTMLElement;
35
+ beforeEach(() => {
36
+ document.body.innerHTML = "";
37
+ host = document.createElement("div");
38
+ document.body.appendChild(host);
39
+ });
40
+
41
+ function buildView(text: string) {
42
+ const { doc, paragraph } = docxSchema.nodes;
43
+ const content = text === "" ? [] : [docxSchema.text(text)];
44
+ const node = doc.create({}, [paragraph.create({}, content)]);
45
+ return createEditableView(host, node, {
46
+ fontRegistry,
47
+ sections: defaultSections,
48
+ });
49
+ }
50
+
51
+ function selectAll(view: ReturnType<typeof buildView>) {
52
+ const { state } = view.editorView;
53
+ const tr = state.tr.setSelection(
54
+ TextSelection.create(state.doc, 1, state.doc.content.size - 1),
55
+ );
56
+ view.editorView.dispatch(tr);
57
+ }
58
+
59
+ describe("template marker parser", () => {
60
+ it("classifies variable / for / end-for / if / end-if", () => {
61
+ expect(parseTemplateMarker("{{name}}").kind).toBe("variable");
62
+ expect(parseTemplateMarker("{{FOR row IN rows}}").kind).toBe("for");
63
+ expect(parseTemplateMarker("{{FOR row IN rows}}").varName).toBe("rows");
64
+ expect(parseTemplateMarker("{{END-FOR rows}}").kind).toBe("end-for");
65
+ expect(parseTemplateMarker("{{IF show}}").kind).toBe("if");
66
+ expect(parseTemplateMarker("{{END-IF show}}").kind).toBe("end-if");
67
+ });
68
+
69
+ it("findTemplateMarkers locates each marker by index", () => {
70
+ const text = "Hello {{name}} from {{$row.label}} now";
71
+ const matches = findTemplateMarkers(text);
72
+ expect(matches).toHaveLength(2);
73
+ expect(matches[0].marker.varName).toBe("name");
74
+ expect(matches[1].marker.varName).toBe("$row.label");
75
+ });
76
+
77
+ it("regex resets between calls (lastIndex side effect)", () => {
78
+ const text = "{{a}} {{b}}";
79
+ expect(findTemplateMarkers(text)).toHaveLength(2);
80
+ expect(findTemplateMarkers(text)).toHaveLength(2);
81
+ });
82
+
83
+ it("uses TEMPLATE_MARKER_RE", () => {
84
+ expect(TEMPLATE_MARKER_RE).toBeInstanceOf(RegExp);
85
+ });
86
+ });
87
+
88
+ describe("template highlight decoration plugin", () => {
89
+ it("renders inline decorations for {{var}}", () => {
90
+ const view = buildView("Hi {{name}}");
91
+ const markers = host.querySelectorAll(".docx-template-marker");
92
+ expect(markers.length).toBeGreaterThanOrEqual(1);
93
+ expect(markers[0].textContent).toBe("{{name}}");
94
+ expect(markers[0].getAttribute("data-template-kind")).toBe("variable");
95
+ view.destroy();
96
+ });
97
+
98
+ it("color-codes loop and conditional markers", () => {
99
+ const view = buildView("a {{FOR r IN rows}} b {{IF show}} c");
100
+ const kinds = Array.from(
101
+ host.querySelectorAll(".docx-template-marker"),
102
+ ).map((el) => el.getAttribute("data-template-kind"));
103
+ expect(kinds).toContain("for");
104
+ expect(kinds).toContain("if");
105
+ view.destroy();
106
+ });
107
+ });
108
+
109
+ describe("template insertion commands", () => {
110
+ it("insertVariable inserts {{name}} at the cursor", () => {
111
+ const view = buildView("");
112
+ insertVariable("company")(view.editorView.state, view.editorView.dispatch);
113
+ expect(view.getDocument().textContent).toBe("{{company}}");
114
+ view.destroy();
115
+ });
116
+
117
+ it("insertLoopVariable produces {{$row.field}}", () => {
118
+ const view = buildView("");
119
+ insertLoopVariable("row", "name")(
120
+ view.editorView.state,
121
+ view.editorView.dispatch,
122
+ );
123
+ expect(view.getDocument().textContent).toBe("{{$row.name}}");
124
+ view.destroy();
125
+ });
126
+
127
+ it("wrapSelectionInLoop brackets the selection with FOR/END-FOR", () => {
128
+ const view = buildView("inner");
129
+ selectAll(view);
130
+ wrapSelectionInLoop("row", "rows")(
131
+ view.editorView.state,
132
+ view.editorView.dispatch,
133
+ );
134
+ expect(view.getDocument().textContent).toBe(
135
+ "{{FOR row IN rows}}inner{{END-FOR rows}}",
136
+ );
137
+ view.destroy();
138
+ });
139
+
140
+ it("wrapSelectionInConditional brackets with IF/END-IF", () => {
141
+ const view = buildView("body");
142
+ selectAll(view);
143
+ wrapSelectionInConditional("show")(
144
+ view.editorView.state,
145
+ view.editorView.dispatch,
146
+ );
147
+ expect(view.getDocument().textContent).toBe(
148
+ "{{IF show}}body{{END-IF show}}",
149
+ );
150
+ view.destroy();
151
+ });
152
+
153
+ it("commands are no-ops with empty input", () => {
154
+ const view = buildView("text");
155
+ const ok = insertVariable("")(
156
+ view.editorView.state,
157
+ view.editorView.dispatch,
158
+ );
159
+ expect(ok).toBe(false);
160
+ view.destroy();
161
+ });
162
+ });
@@ -0,0 +1,115 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { cleanWordHtml, buildClipboardHooks } from "./clipboard";
4
+ import { createImageRegistry } from "../pm/image_registry";
5
+ import { docxSchema } from "../pm/schema";
6
+ import { createEditableView } from "./view";
7
+ import { buildFontRegistry } from "../fonts/registry";
8
+ import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
9
+ import type { Section } from "../model/sections";
10
+
11
+ describe("cleanWordHtml", () => {
12
+ it("strips conditional [if] mso comments", () => {
13
+ const html = `<p>before<!--[if !supportLists]>•<![endif]-->after</p>`;
14
+ const cleaned = cleanWordHtml(html);
15
+ expect(cleaned).not.toContain("[if");
16
+ expect(cleaned).not.toContain("supportLists");
17
+ expect(cleaned).toContain("before");
18
+ expect(cleaned).toContain("after");
19
+ });
20
+
21
+ it("removes <o:p> empty paragraphs", () => {
22
+ const html = `<p>real</p><o:p></o:p><o:p>&nbsp;</o:p>`;
23
+ const cleaned = cleanWordHtml(html);
24
+ expect(cleaned).not.toContain("o:p");
25
+ expect(cleaned).toContain("real");
26
+ });
27
+
28
+ it("strips namespaced w:/m:/v: tags from Word HTML", () => {
29
+ // Word-flavoured HTML: contains an mso-* marker, so the aggressive
30
+ // namespace-tag strip runs.
31
+ const html = `<p style="mso-list:none;">text<w:sym /><m:eq>x</m:eq><v:rect /></p>`;
32
+ const cleaned = cleanWordHtml(html);
33
+ expect(cleaned).not.toContain("<w:");
34
+ expect(cleaned).not.toContain("<m:");
35
+ expect(cleaned).not.toContain("<v:");
36
+ expect(cleaned).toContain("text");
37
+ });
38
+
39
+ it("preserves namespaced tags when the source is not Word HTML", () => {
40
+ // Non-Word source: no mso-*/o:p/Mso markers. Aggressive Word strips
41
+ // are skipped so legitimate paste isn't damaged.
42
+ const html = `<p>text<custom:tag /></p>`;
43
+ const cleaned = cleanWordHtml(html);
44
+ expect(cleaned).toContain("<custom:tag");
45
+ expect(cleaned).toContain("text");
46
+ });
47
+
48
+ it("removes mso-* style declarations from inline styles", () => {
49
+ const html = `<p style="margin-top:0;mso-list:none;color:red;mso-pagination:widow-orphan">x</p>`;
50
+ const cleaned = cleanWordHtml(html);
51
+ expect(cleaned).not.toContain("mso-");
52
+ expect(cleaned).toContain("margin-top:0");
53
+ expect(cleaned).toContain("color:red");
54
+ });
55
+
56
+ it("strips class names containing Mso", () => {
57
+ const html = `<p class="MsoNormal">a</p><span class="MsoListParagraph">b</span>`;
58
+ const cleaned = cleanWordHtml(html);
59
+ expect(cleaned).not.toContain("Mso");
60
+ expect(cleaned).toContain("a");
61
+ });
62
+
63
+ it("removes empty style attributes left behind", () => {
64
+ const html = `<p style="mso-pagination:widow-orphan">x</p>`;
65
+ const cleaned = cleanWordHtml(html);
66
+ expect(cleaned).not.toContain("style=\"\"");
67
+ });
68
+
69
+ it("preserves structural tags untouched", () => {
70
+ const html = `<table><tr><td><p>cell</p></td></tr></table>`;
71
+ expect(cleanWordHtml(html)).toBe(html);
72
+ });
73
+
74
+ it("removes generic HTML comments", () => {
75
+ const html = `<p>x<!-- a note -->y</p>`;
76
+ expect(cleanWordHtml(html)).toBe(`<p>xy</p>`);
77
+ });
78
+ });
79
+
80
+ describe("clipboard hooks integration", () => {
81
+ let host: HTMLElement;
82
+ beforeEach(() => {
83
+ document.body.innerHTML = "";
84
+ host = document.createElement("div");
85
+ document.body.appendChild(host);
86
+ });
87
+
88
+ it("buildClipboardHooks() returns transformPastedHTML always; paste/drop only when registry present", () => {
89
+ expect(buildClipboardHooks().transformPastedHTML("x")).toBe("x");
90
+ expect(buildClipboardHooks().handlePaste).toBeUndefined();
91
+ expect(buildClipboardHooks().handleDrop).toBeUndefined();
92
+
93
+ const reg = createImageRegistry();
94
+ const hooks = buildClipboardHooks({ imageRegistry: reg });
95
+ expect(hooks.handlePaste).toBeDefined();
96
+ expect(hooks.handleDrop).toBeDefined();
97
+ });
98
+
99
+ it("EditorView wires transformPastedHTML through to PM", () => {
100
+ const { doc, paragraph } = docxSchema.nodes;
101
+ const node = doc.create({}, [paragraph.create({}, [])]);
102
+ const view = createEditableView(host, node, {
103
+ fontRegistry: buildFontRegistry({ embeddedFonts: [], workspaceFonts: [] }),
104
+ sections: [
105
+ {
106
+ properties: { ...DEFAULT_SECTION_PROPERTIES },
107
+ blockStartIndex: 0,
108
+ blockEndIndex: 0,
109
+ } satisfies Section,
110
+ ],
111
+ });
112
+ expect(view.editorView.editable).toBe(true);
113
+ view.destroy();
114
+ });
115
+ });