smartrte-react 0.3.4 → 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.
Files changed (87) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +14 -83
  3. package/dist/adapters/canonicalInlineCommandBridge.d.ts +15 -0
  4. package/dist/adapters/canonicalInlineCommandBridge.js +269 -0
  5. package/dist/adapters/canonicalListCommandBridge.d.ts +26 -0
  6. package/dist/adapters/canonicalListCommandBridge.js +430 -0
  7. package/dist/adapters/documentFormats.d.ts +26 -0
  8. package/dist/adapters/documentFormats.js +49 -0
  9. package/dist/adapters/docxFormat.d.ts +5 -0
  10. package/dist/adapters/docxFormat.js +272 -0
  11. package/dist/adapters/domBlockCommandBridge.d.ts +19 -0
  12. package/dist/adapters/domBlockCommandBridge.js +79 -0
  13. package/dist/adapters/domChecklistCommandBridge.d.ts +6 -0
  14. package/dist/adapters/domChecklistCommandBridge.js +33 -0
  15. package/dist/adapters/domCommandBridge.d.ts +19 -0
  16. package/dist/adapters/domCommandBridge.js +33 -0
  17. package/dist/adapters/domInlineAtomCommandBridge.d.ts +14 -0
  18. package/dist/adapters/domInlineAtomCommandBridge.js +101 -0
  19. package/dist/adapters/domInlineImageCommandBridge.d.ts +14 -0
  20. package/dist/adapters/domInlineImageCommandBridge.js +72 -0
  21. package/dist/adapters/domMoveCommandBridge.d.ts +7 -0
  22. package/dist/adapters/domMoveCommandBridge.js +54 -0
  23. package/dist/adapters/domSelectionBridge.d.ts +3 -1
  24. package/dist/adapters/domSelectionBridge.js +96 -44
  25. package/dist/adapters/domSmartDocument.js +158 -16
  26. package/dist/adapters/domTableCommandBridge.d.ts +35 -0
  27. package/dist/adapters/domTableCommandBridge.js +201 -0
  28. package/dist/adapters/legacyListShadowComparator.d.ts +26 -0
  29. package/dist/adapters/legacyListShadowComparator.js +316 -0
  30. package/dist/adapters/pdfFormat.d.ts +23 -0
  31. package/dist/adapters/pdfFormat.js +204 -0
  32. package/dist/adapters/portableDocxAtoms.d.ts +4 -0
  33. package/dist/adapters/portableDocxAtoms.js +58 -0
  34. package/dist/adapters/styledDocxFormat.d.ts +8 -0
  35. package/dist/adapters/styledDocxFormat.js +190 -0
  36. package/dist/builtInFormatDefinitions.d.ts +5 -0
  37. package/dist/builtInFormatDefinitions.js +82 -0
  38. package/dist/canonicalAuthorityFlag.d.ts +32 -0
  39. package/dist/canonicalAuthorityFlag.js +49 -0
  40. package/dist/canonicalClipboardRuntime.d.ts +15 -0
  41. package/dist/canonicalClipboardRuntime.js +76 -0
  42. package/dist/canonicalEditorRuntime.d.ts +77 -0
  43. package/dist/canonicalEditorRuntime.js +274 -0
  44. package/dist/components/CanonicalAuthorityEditor.d.ts +25 -0
  45. package/dist/components/CanonicalAuthorityEditor.js +518 -0
  46. package/dist/components/ClassicEditor.d.ts +13 -3
  47. package/dist/components/ClassicEditor.js +1226 -1114
  48. package/dist/components/ClassicEditorAuthority.d.ts +38 -0
  49. package/dist/components/ClassicEditorAuthority.js +49 -0
  50. package/dist/components/MediaPicker.d.ts +10 -0
  51. package/dist/components/MediaPicker.js +16 -0
  52. package/dist/editorController.d.ts +78 -0
  53. package/dist/editorController.js +298 -0
  54. package/dist/formatFidelity.d.ts +14 -0
  55. package/dist/formatFidelity.js +107 -0
  56. package/dist/formatRuntime.d.ts +50 -0
  57. package/dist/formatRuntime.js +20 -0
  58. package/dist/index.d.ts +24 -0
  59. package/dist/index.js +10 -0
  60. package/dist/mediaProvider.d.ts +39 -0
  61. package/dist/mediaProvider.js +1 -0
  62. package/dist/pluginRuntime.d.ts +68 -0
  63. package/dist/pluginRuntime.js +78 -0
  64. package/dist/standalone/editor.js +722 -495
  65. package/dist/test-harness/atomShadowComparator.d.ts +14 -0
  66. package/dist/test-harness/atomShadowComparator.js +63 -0
  67. package/dist/test-harness/blockShadowComparator.d.ts +30 -0
  68. package/dist/test-harness/blockShadowComparator.js +140 -0
  69. package/dist/test-harness/clipboardShadowComparator.d.ts +12 -0
  70. package/dist/test-harness/clipboardShadowComparator.js +46 -0
  71. package/dist/test-harness/inlineShadowComparator.d.ts +32 -0
  72. package/dist/test-harness/inlineShadowComparator.js +152 -0
  73. package/dist/test-harness/legacyAtomEngine.d.ts +10 -0
  74. package/dist/test-harness/legacyAtomEngine.js +15 -0
  75. package/dist/test-harness/legacyBlockEngine.d.ts +31 -0
  76. package/dist/test-harness/legacyBlockEngine.js +31 -0
  77. package/dist/test-harness/legacyClipboardEngine.d.ts +14 -0
  78. package/dist/test-harness/legacyClipboardEngine.js +67 -0
  79. package/dist/test-harness/legacyInlineEngine.d.ts +19 -0
  80. package/dist/test-harness/legacyInlineEngine.js +49 -0
  81. package/dist/test-harness/legacyTableEngine.d.ts +12 -0
  82. package/dist/test-harness/legacyTableEngine.js +25 -0
  83. package/dist/test-harness/tableShadowComparator.d.ts +29 -0
  84. package/dist/test-harness/tableShadowComparator.js +101 -0
  85. package/dist/theme.d.ts +1 -1
  86. package/dist/theme.js +28 -0
  87. package/package.json +1 -1
@@ -2,16 +2,19 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { MediaManager } from "./MediaManager.js";
4
4
  import { LinkEditorPopover } from "./LinkEditorPopover.js";
5
- import * as pdfjsLib from 'pdfjs-dist';
6
- import mammoth from 'mammoth';
7
- import JSZip from 'jszip';
8
- import { applyLink, applyTextColor as coreApplyTextColor, markdownToCompatibilityHtml, removeLink, sanitizeLinkHref, toggleBold, toggleItalic, toggleSubscript, toggleSuperscript, toggleUnderline } from 'smartrte-core';
5
+ import { applyLink, applyTextColor as coreApplyTextColor, getSmartListPreset, isSmartListPreset, listStyleForPresetDepth, removeLink, sanitizeLinkHref, SMART_LIST_PRESETS, toggleBold, toggleItalic, toggleSubscript, toggleSuperscript, toggleUnderline } from 'smartrte-core';
6
+ import { executeDomMoveCommand } from '../adapters/domMoveCommandBridge.js';
7
+ import { adjacentInlineAtom } from '../adapters/domInlineAtomCommandBridge.js';
9
8
  import { restoreSelectionToDom, selectionFromDom } from '../adapters/domSelectionBridge.js';
10
9
  import { serializeSmartDocument, smartDocumentFromEditorRoot } from '../adapters/domSmartDocument.js';
11
10
  import { isShadowModeEnabled, runShadowCommand } from '../adapters/shadowMode.js';
12
11
  import { getCoreInlineMarkResult, isCoreInlineMarkEnabled } from '../adapters/inlineMarkCoreExecution.js';
13
12
  import { closestFromTarget, isNode } from '../adapters/domTargets.js';
14
13
  import { ensureStyleSheet } from '../theme.js';
14
+ import { createReactEditorPluginRuntime, matchesPluginShortcut } from '../pluginRuntime.js';
15
+ import { createEditorFormatRuntime } from '../formatRuntime.js';
16
+ import { createBuiltInFormatDefinitions } from '../builtInFormatDefinitions.js';
17
+ import { createDomEditorController } from '../editorController.js';
15
18
  const iconPaths = {
16
19
  undo: _jsxs(_Fragment, { children: [_jsx("path", { d: "M9 7 5 11l4 4" }), _jsx("path", { d: "M5 11h7a5 5 0 0 1 5 5" })] }),
17
20
  redo: _jsxs(_Fragment, { children: [_jsx("path", { d: "m15 7 4 4-4 4" }), _jsx("path", { d: "M19 11h-7a5 5 0 0 0-5 5" })] }),
@@ -78,11 +81,7 @@ function ToolbarMenu({ label, icon, active, priority, children }) {
78
81
  function MenuItem({ icon, label, active, disabled, onClick, title }) {
79
82
  return _jsxs("button", { type: "button", role: "menuitem", className: "srte-menu-item", "aria-label": title || label, "aria-pressed": Boolean(active), "aria-checked": active || undefined, disabled: disabled, title: title || label, onClick: (event) => { onClick(); event.currentTarget.closest("details")?.removeAttribute("open"); }, children: [_jsx(ToolbarIcon, { name: icon }), _jsx("span", { children: label }), active && _jsx("span", { className: "srte-menu-check", "aria-hidden": "true", children: "\u2713" })] });
80
83
  }
81
- // Initialize PDF.js worker
82
- if (typeof window !== 'undefined') {
83
- pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
84
- }
85
- export function ClassicEditor({ value, onChange, placeholder = "Type here…", minHeight = 200, maxHeight = 500, readOnly = false, table = true, media = true, formula = true, mediaManager, fonts = [
84
+ export function ClassicEditor({ value, onChange, placeholder = "Type here…", minHeight = 200, maxHeight = 500, readOnly = false, table: legacyTable = true, media: legacyMedia = true, formula: legacyFormula = true, features, plugins, formats, formatDefinitions, mediaManager, fonts = [
86
85
  { name: "Arial", value: "Arial, Helvetica, sans-serif" },
87
86
  { name: "Georgia", value: "Georgia, serif" },
88
87
  { name: "Impact", value: "Impact, Charcoal, sans-serif" },
@@ -92,18 +91,50 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
92
91
  { name: "Courier New", value: "'Courier New', Courier, monospace" },
93
92
  ], defaultFont, preserveFontFamily = false, preserveColors = false, preserveDocxStyles = true, theme = "light", showFontSize = true, className, }) {
94
93
  ensureStyleSheet();
94
+ const pluginRuntime = createReactEditorPluginRuntime({
95
+ ...(plugins ? { plugins } : {
96
+ features: {
97
+ ...features,
98
+ table: features?.table ?? legacyTable,
99
+ media: features?.media ?? legacyMedia,
100
+ formula: features?.formula ?? legacyFormula,
101
+ },
102
+ }),
103
+ });
104
+ const formatRuntime = createEditorFormatRuntime(formats, [
105
+ ...(formatDefinitions ?? createBuiltInFormatDefinitions({ preserveDocxStyles })),
106
+ ...pluginRuntime.formats,
107
+ ]);
108
+ const proxyFormatLabel = (format) => format.id === "docx" ? "Word" : format.label;
109
+ const proxyExportFormats = [
110
+ ...["html", "markdown", "docx", "pdf"].flatMap((id) => formatRuntime.exports.filter((format) => format.id === id)),
111
+ ...formatRuntime.exports.filter((format) => !["html", "markdown", "docx", "pdf"].includes(format.id)),
112
+ ];
113
+ const table = pluginRuntime.hasFeature("table");
114
+ const media = pluginRuntime.hasFeature("media");
115
+ const formula = pluginRuntime.hasFeature("formula");
116
+ const listFeature = pluginRuntime.hasFeature("list");
117
+ const checklistFeature = pluginRuntime.hasFeature("checklist");
118
+ const blockquoteFeature = pluginRuntime.hasFeature("blockquote");
119
+ const codeBlockFeature = pluginRuntime.hasFeature("codeBlock");
120
+ const alignmentFeature = pluginRuntime.hasFeature("alignment");
121
+ const blockTypeFeature = pluginRuntime.hasFeature("blockType");
122
+ const basicFormattingFeature = pluginRuntime.hasFeature("basicFormatting");
123
+ const moveFeature = pluginRuntime.hasFeature("move");
95
124
  const editableRef = useRef(null);
125
+ const editorControllerRef = useRef(null);
126
+ if (!editorControllerRef.current)
127
+ editorControllerRef.current = createDomEditorController();
128
+ editorControllerRef.current.configure({
129
+ plugins: pluginRuntime.plugins,
130
+ readOnly,
131
+ });
96
132
  const editorScrollRef = useRef(null);
97
133
  const lastEmittedRef = useRef("");
98
134
  const isComposingRef = useRef(false);
99
135
  const fileInputRef = useRef(null);
100
- const pdfInputRef = useRef(null);
101
- const docxInputRef = useRef(null);
102
- const htmlInputRef = useRef(null);
103
- const mdInputRef = useRef(null);
104
136
  const [loadingPdf, setLoadingPdf] = useState(false);
105
137
  const [loadingDocx, setLoadingDocx] = useState(false);
106
- // State for import confirmation
107
138
  const [pendingImport, setPendingImport] = useState(null);
108
139
  const replaceTargetRef = useRef(null);
109
140
  const [selectedImage, setSelectedImage] = useState(null);
@@ -124,6 +155,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
124
155
  const tableFillTargetsRef = useRef(null);
125
156
  const selectingRef = useRef(null);
126
157
  const [imageMenu, setImageMenu] = useState(null);
158
+ const [pluginContextMenu, setPluginContextMenu] = useState(null);
127
159
  const [linkMenu, setLinkMenu] = useState(null);
128
160
  const [showMediaManager, setShowMediaManager] = useState(false);
129
161
  const [showColorPicker, setShowColorPicker] = useState(false);
@@ -132,10 +164,6 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
132
164
  const savedRangeRef = useRef(null);
133
165
  const pendingFontSizeRef = useRef(null);
134
166
  const inlineScriptCaretOverrideRef = useRef(null);
135
- const historyRef = useRef({
136
- undo: [],
137
- redo: [],
138
- });
139
167
  const inputHistoryGroupRef = useRef(null);
140
168
  const [currentFontSize, setCurrentFontSize] = useState("");
141
169
  const [currentFont, setCurrentFont] = useState("");
@@ -167,6 +195,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
167
195
  fixNegativeMargins(el);
168
196
  normalizeInvalidQuoteNesting(el);
169
197
  normalizeInvalidCodeBlockNesting(el);
198
+ normalizeBlockContainerStyling(el);
170
199
  normalizeInvalidTableNesting(el);
171
200
  ensureTableWrappers(el);
172
201
  ensureCaretBoundaryParagraphs(el);
@@ -174,6 +203,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
174
203
  }
175
204
  normalizeInvalidQuoteNesting(el);
176
205
  normalizeInvalidCodeBlockNesting(el);
206
+ normalizeBlockContainerStyling(el);
177
207
  normalizeInvalidTableNesting(el);
178
208
  ensureCaretBoundaryParagraphs(el);
179
209
  // Suppress native context menu inside table cells at capture phase
@@ -277,8 +307,19 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
277
307
  if (!inEditor)
278
308
  return;
279
309
  let node = range.commonAncestorContainer;
280
- if (node.nodeType === Node.TEXT_NODE)
310
+ if (node.nodeType === Node.TEXT_NODE) {
281
311
  node = node.parentNode;
312
+ }
313
+ else if (range.collapsed && node instanceof HTMLElement) {
314
+ // A caret collapsed "between children" of a container (e.g. left
315
+ // there by focusElementEnd(list) after unwrapping a blockquote)
316
+ // must resolve down into that container's actual content, or every
317
+ // active-state indicator below silently reports "not active" even
318
+ // though the caret is sitting right next to a blockquote/pre.
319
+ node = resolveCollapsedElementPosition(node, range.startOffset);
320
+ if (node.nodeType === Node.TEXT_NODE)
321
+ node = node.parentNode;
322
+ }
282
323
  const element = node instanceof HTMLElement ? node : null;
283
324
  const block = element?.closest("p,h1,h2,h3,h4,h5,h6,li,blockquote,pre,div");
284
325
  const tag = block?.tagName.toLowerCase();
@@ -582,13 +623,39 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
582
623
  return () => editor.removeEventListener("beforeinput", applyPendingInlineScript);
583
624
  }, []);
584
625
  const applyFormatBlock = (blockName) => {
626
+ if (!blockTypeFeature)
627
+ return;
585
628
  try {
586
629
  if (!restoreSavedSelection()) {
587
630
  safeSelectRange(getSelectionRangeInEditor());
588
631
  }
589
632
  pushEditorHistory();
633
+ const range = getSelectionRangeInEditor();
634
+ const tag = normalizeBlockTag(blockName);
635
+ const blocks = range
636
+ ? (range.collapsed
637
+ ? [getCurrentBlock()].filter((block) => Boolean(block))
638
+ : getSelectedBlocks(range))
639
+ : [];
640
+ const coreBlocks = blocks.filter((block) => block.matches("p,h1,h2,h3,h4,h5,h6") && !block.closest("li,td,th"));
641
+ if (coreBlocks.length === blocks.length &&
642
+ coreBlocks.length > 0 &&
643
+ (tag === "p" || /^h[1-6]$/.test(tag || ""))) {
644
+ const replacements = editorControllerRef.current.bindRoot(editableRef.current).executeBlockCommand(coreBlocks, {
645
+ id: "block-type.set",
646
+ input: tag === "p"
647
+ ? { type: "paragraph" }
648
+ : { type: "heading", level: Number(tag.slice(1)) },
649
+ });
650
+ if (replacements) {
651
+ focusElementEnd(replacements[replacements.length - 1]);
652
+ setCurrentBlockType(tag);
653
+ handleInput();
654
+ requestAnimationFrame(updateActiveState);
655
+ return;
656
+ }
657
+ }
590
658
  if (applyFormatBlockFallback(blockName)) {
591
- const tag = normalizeBlockTag(blockName);
592
659
  if (tag === "p" || /^h[1-6]$/.test(tag || "")) {
593
660
  setCurrentBlockType(tag);
594
661
  }
@@ -637,37 +704,19 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
637
704
  cell.style.outline = outline;
638
705
  cell.style.outlineOffset = outlineOffset;
639
706
  });
640
- const undo = historyRef.current.undo;
641
- if (undo[undo.length - 1] !== html) {
642
- undo.push(html);
643
- if (undo.length > 100)
644
- undo.shift();
645
- }
646
- historyRef.current.redo = [];
707
+ editorControllerRef.current.bindRoot(editor).recordHistorySnapshot(html);
647
708
  };
648
709
  const restoreEditorHistory = (dir) => {
649
710
  const editor = editableRef.current;
650
711
  if (!editor)
651
712
  return false;
652
- const history = historyRef.current;
653
- const from = dir === "undo" ? history.undo : history.redo;
654
- const to = dir === "undo" ? history.redo : history.undo;
655
- const currentHtml = editor.innerHTML;
656
- let html;
657
- while (from.length > 0) {
658
- const candidate = from.pop();
659
- if (candidate !== currentHtml) {
660
- html = candidate;
661
- break;
662
- }
663
- }
664
- if (html == null)
713
+ const restored = editorControllerRef.current.bindRoot(editor).restoreHistory(dir);
714
+ if (!restored)
665
715
  return false;
666
- if (to[to.length - 1] !== currentHtml)
667
- to.push(currentHtml);
668
- editor.innerHTML = html;
716
+ const html = restored.html;
669
717
  fixNegativeMargins(editor);
670
718
  normalizeInvalidCodeBlockNesting(editor);
719
+ normalizeBlockContainerStyling(editor);
671
720
  ensureTableWrappers(editor);
672
721
  ensureCaretBoundaryParagraphs(editor);
673
722
  addTableResizeHandles();
@@ -850,14 +899,35 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
850
899
  range.collapse(false);
851
900
  return range;
852
901
  };
902
+ // A collapsed range positioned directly on an element (e.g. after
903
+ // focusElementEnd collapses "inside" a blockquote rather than into one of
904
+ // its text nodes) sits between that element's children, not inside any of
905
+ // them. Resolve down to the actual child content at that position so a
906
+ // container like blockquote/li/ul/ol/td/th is never mistaken for the
907
+ // current block itself.
908
+ const resolveCollapsedElementPosition = (element, offset) => {
909
+ let node = element.childNodes[offset] ?? element.childNodes[offset - 1] ?? element;
910
+ while (node instanceof HTMLElement &&
911
+ node.matches("blockquote,li,ul,ol,td,th") &&
912
+ node.childNodes.length) {
913
+ node = node.lastChild;
914
+ }
915
+ return node;
916
+ };
853
917
  const getCurrentBlock = () => {
854
918
  const editor = editableRef.current;
855
919
  const range = getSelectionRangeInEditor();
856
920
  if (!editor || !range)
857
921
  return null;
858
922
  let node = range.startContainer;
859
- if (node.nodeType === Node.TEXT_NODE)
923
+ if (node.nodeType === Node.TEXT_NODE) {
860
924
  node = node.parentNode;
925
+ }
926
+ else if (node instanceof HTMLElement) {
927
+ node = resolveCollapsedElementPosition(node, range.startOffset);
928
+ if (node.nodeType === Node.TEXT_NODE)
929
+ node = node.parentNode;
930
+ }
861
931
  const element = node instanceof HTMLElement ? node : null;
862
932
  const cell = element?.closest("td,th");
863
933
  if (cell && editor.contains(cell)) {
@@ -875,6 +945,22 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
875
945
  return block;
876
946
  };
877
947
  const blockSelector = "p,h1,h2,h3,h4,h5,h6,li,blockquote,pre,div";
948
+ // Finds the nearest blockSelector ancestor without leaping over an
949
+ // intervening ul/ol/blockquote: those are containers, not interchangeable
950
+ // wrappers, so a candidate nested inside one is never mistaken for being
951
+ // owned by a distant container beyond it (e.g. a blockquote wrapping the
952
+ // whole document, or a list several levels up).
953
+ const findOwningBlock = (start) => {
954
+ let node = start;
955
+ while (node) {
956
+ if (node.matches("ul,ol,blockquote"))
957
+ return null;
958
+ if (node.matches(blockSelector))
959
+ return node;
960
+ node = node.parentElement;
961
+ }
962
+ return null;
963
+ };
878
964
  const getSelectedBlocks = (range) => {
879
965
  const editor = editableRef.current;
880
966
  if (!editor)
@@ -888,32 +974,50 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
888
974
  const startCell = startElement?.closest?.("td,th");
889
975
  const endCell = endElement?.closest?.("td,th");
890
976
  const scope = startCell && startCell === endCell && editor.contains(startCell) ? startCell : editor;
891
- const blocks = Array.from(scope.querySelectorAll(blockSelector)).filter((block) => {
977
+ const intersects = (node) => {
978
+ try {
979
+ return range.intersectsNode(node);
980
+ }
981
+ catch {
982
+ return false;
983
+ }
984
+ };
985
+ const candidates = Array.from(scope.querySelectorAll(blockSelector)).filter((block) => {
892
986
  if (block === editor || block.getAttribute("data-table-wrapper") === "true")
893
987
  return false;
894
- const parentBlock = block.parentElement?.closest(blockSelector);
895
- if (parentBlock && scope.contains(parentBlock) && parentBlock !== scope)
988
+ // Caret-boundary spacer paragraphs exist only to give the caret a click
989
+ // target next to a blockquote/pre/table; they aren't real content and
990
+ // must never be swept into a broader selection's block set.
991
+ if (block.getAttribute("data-srte-caret-boundary") === "true")
896
992
  return false;
897
- try {
898
- if (!range.intersectsNode(block))
993
+ if (!intersects(block))
994
+ return false;
995
+ const parent = block.parentNode;
996
+ if (parent === range.endContainer) {
997
+ const index = Array.prototype.indexOf.call(parent.childNodes, block);
998
+ if (range.endOffset <= index)
899
999
  return false;
900
- const parent = block.parentNode;
901
- if (parent === range.endContainer) {
902
- const index = Array.prototype.indexOf.call(parent.childNodes, block);
903
- if (range.endOffset <= index)
904
- return false;
905
- }
906
- if (parent === range.startContainer) {
907
- const index = Array.prototype.indexOf.call(parent.childNodes, block);
908
- if (range.startOffset > index)
909
- return false;
910
- }
911
- return true;
912
1000
  }
913
- catch {
914
- return false;
1001
+ if (parent === range.startContainer) {
1002
+ const index = Array.prototype.indexOf.call(parent.childNodes, block);
1003
+ if (range.startOffset > index)
1004
+ return false;
915
1005
  }
1006
+ return true;
1007
+ });
1008
+ // Prefer a candidate's owning block (e.g. an li wrapping a single p) only
1009
+ // when that owner has no other content outside the selection — otherwise
1010
+ // the owner is too broad (a container spanning many unrelated lines, or
1011
+ // the whole document) and the more specific candidate stands on its own.
1012
+ const stage1 = candidates.filter((block) => {
1013
+ const parentBlock = findOwningBlock(block.parentElement);
1014
+ if (!parentBlock || !scope.contains(parentBlock) || parentBlock === scope)
1015
+ return true;
1016
+ return Array.from(parentBlock.querySelectorAll(blockSelector)).some((sibling) => sibling !== block && findOwningBlock(sibling.parentElement) === parentBlock && !intersects(sibling));
916
1017
  });
1018
+ // Drop any survivor that only qualifies because a more specific
1019
+ // descendant survivor is nested inside it.
1020
+ const blocks = stage1.filter((block) => !stage1.some((other) => other !== block && block.contains(other)));
917
1021
  if (blocks.length > 0)
918
1022
  return sortInDocumentOrder(blocks);
919
1023
  const current = getCurrentBlock();
@@ -924,9 +1028,15 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
924
1028
  if (!editor)
925
1029
  return [];
926
1030
  if (range.collapsed) {
927
- const element = range.startContainer instanceof HTMLElement
928
- ? range.startContainer
929
- : range.startContainer.parentElement;
1031
+ // A caret left positioned "between children" of a container (e.g. via
1032
+ // focusElementEnd(ul) after unwrapping a blockquote) needs the same
1033
+ // drill-down as getCurrentBlock, or a list item is missed entirely
1034
+ // whenever the collapsed point sits on the list/blockquote itself
1035
+ // rather than inside one of its descendant text nodes.
1036
+ let node = range.startContainer;
1037
+ if (node instanceof HTMLElement)
1038
+ node = resolveCollapsedElementPosition(node, range.startOffset);
1039
+ const element = node instanceof HTMLElement ? node : node.parentElement;
930
1040
  const item = element?.closest("li");
931
1041
  return item && editor.contains(item) ? [item] : [];
932
1042
  }
@@ -942,6 +1052,22 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
942
1052
  });
943
1053
  return sortInDocumentOrder(Array.from(editor.querySelectorAll("li")).filter(ownsSelectedContent));
944
1054
  };
1055
+ const resolveCommonSelectedList = (items) => {
1056
+ if (items.length === 0)
1057
+ return null;
1058
+ const listChain = (item) => {
1059
+ const lists = [];
1060
+ let list = item.parentElement?.matches("ul,ol") ? item.parentElement : null;
1061
+ while (list) {
1062
+ lists.push(list);
1063
+ const ownerItem = list.parentElement?.closest("li");
1064
+ list = ownerItem?.parentElement?.matches("ul,ol") ? ownerItem.parentElement : null;
1065
+ }
1066
+ return lists;
1067
+ };
1068
+ const chains = items.map(listChain);
1069
+ return chains[0].find((candidate) => chains.every((chain) => chain.includes(candidate))) || null;
1070
+ };
945
1071
  const getAlignmentTarget = (block) => {
946
1072
  const item = block.tagName === "LI" ? block : block.closest("li");
947
1073
  return item instanceof HTMLElement ? item : block;
@@ -981,6 +1107,8 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
981
1107
  : "left";
982
1108
  };
983
1109
  const applyTextAlignment = (alignment) => {
1110
+ if (!alignmentFeature)
1111
+ return;
984
1112
  try {
985
1113
  if (!restoreSavedSelection())
986
1114
  safeSelectRange(getSelectionRangeInEditor());
@@ -991,6 +1119,26 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
991
1119
  if (targets.length === 0)
992
1120
  return;
993
1121
  pushEditorHistory();
1122
+ const coreTargets = targets.filter((target) => target.matches("p,h1,h2,h3,h4,h5,h6,blockquote,pre") &&
1123
+ !target.closest("li"));
1124
+ if (coreTargets.length === targets.length) {
1125
+ const groups = new Map();
1126
+ coreTargets.forEach((target) => {
1127
+ const parent = target.parentElement;
1128
+ if (parent)
1129
+ groups.set(parent, [...(groups.get(parent) || []), target]);
1130
+ });
1131
+ const replacements = Array.from(groups.values()).flatMap((group) => editorControllerRef.current.bindRoot(editableRef.current).executeBlockCommand(group, {
1132
+ id: "alignment.set",
1133
+ input: { alignment },
1134
+ }) || []);
1135
+ if (replacements.length === coreTargets.length) {
1136
+ focusElementEnd(replacements[replacements.length - 1]);
1137
+ handleInput();
1138
+ requestAnimationFrame(updateActiveState);
1139
+ return;
1140
+ }
1141
+ }
994
1142
  targets.forEach((target) => {
995
1143
  target.style.textAlign = alignment === "left" ? "" : alignment;
996
1144
  if (!target.getAttribute("style"))
@@ -1213,6 +1361,8 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1213
1361
  const isCompatible = (candidate) => candidate instanceof HTMLElement &&
1214
1362
  candidate.tagName === list.tagName &&
1215
1363
  candidate.style.listStyleType === list.style.listStyleType &&
1364
+ candidate.dataset.srteListPreset === list.dataset.srteListPreset &&
1365
+ candidate.dataset.srteListDepth === list.dataset.srteListDepth &&
1216
1366
  candidate.dataset.srteChecklist === list.dataset.srteChecklist &&
1217
1367
  candidate.dataset.srteChecklistStrike === list.dataset.srteChecklistStrike;
1218
1368
  let merged = list;
@@ -1338,6 +1488,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1338
1488
  list.parentElement?.replaceChild(replacement, list);
1339
1489
  const li = replacement.querySelector("li");
1340
1490
  focusElementEnd(li || replacement);
1491
+ return replacement;
1341
1492
  };
1342
1493
  const getOrCreateNestedList = (item, source) => {
1343
1494
  const compatible = Array.from(item.children).find((child) => child instanceof HTMLElement &&
@@ -1470,9 +1621,28 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1470
1621
  return list?.parentElement?.tagName === "LI" && ["UL", "OL"].includes(list.parentElement.parentElement?.tagName || "");
1471
1622
  });
1472
1623
  const applyListDepthChange = (items, direction) => {
1624
+ if (!listFeature)
1625
+ return false;
1473
1626
  const enabled = direction === "indent" ? canIndentListItems(items) : canOutdentListItems(items);
1474
1627
  if (!enabled)
1475
1628
  return false;
1629
+ const editor = editableRef.current;
1630
+ if (editor && items.every((item) => !item.closest("td,th,[data-srte-checklist='true']"))) {
1631
+ const beforeCoreHtml = editor.innerHTML;
1632
+ const result = editorControllerRef.current
1633
+ .bindRoot(editor)
1634
+ .execute(direction === "indent" ? "list.indent" : "list.outdent");
1635
+ if (result) {
1636
+ inputHistoryGroupRef.current = null;
1637
+ editorControllerRef.current.bindRoot(editor).recordHistorySnapshot(beforeCoreHtml);
1638
+ const selection = window.getSelection();
1639
+ if (selection?.rangeCount)
1640
+ savedRangeRef.current = selection.getRangeAt(0).cloneRange();
1641
+ handleInput();
1642
+ requestAnimationFrame(updateActiveState);
1643
+ return true;
1644
+ }
1645
+ }
1476
1646
  pushEditorHistory();
1477
1647
  const changed = direction === "indent"
1478
1648
  ? nestSelectedListItems(items)
@@ -1527,6 +1697,86 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1527
1697
  });
1528
1698
  return lastSelected;
1529
1699
  };
1700
+ const listStylesForRoot = (listTag, rootStyle) => {
1701
+ if (listTag === "ul") {
1702
+ if (rootStyle === "circle")
1703
+ return ["circle", "square", "disc"];
1704
+ if (rootStyle === "square")
1705
+ return ["square", "circle", "disc"];
1706
+ return ["disc", "circle", "square"];
1707
+ }
1708
+ if (rootStyle === "upper-alpha")
1709
+ return ["upper-alpha", "lower-alpha", "lower-roman"];
1710
+ if (rootStyle === "upper-roman")
1711
+ return ["upper-roman", "upper-alpha", "decimal"];
1712
+ // Legacy single-style choices remain stable across the first nested level.
1713
+ // Depth-aware families are represented by named presets above.
1714
+ if (rootStyle === "lower-alpha")
1715
+ return ["lower-alpha", "lower-alpha", "lower-roman"];
1716
+ if (rootStyle === "lower-roman")
1717
+ return ["lower-roman", "lower-roman", "lower-alpha"];
1718
+ if (rootStyle === "decimal-leading-zero")
1719
+ return ["decimal-leading-zero", "lower-alpha", "lower-roman"];
1720
+ return ["decimal", "lower-alpha", "lower-roman"];
1721
+ };
1722
+ const clearListPreset = (list) => {
1723
+ delete list.dataset.srteListPreset;
1724
+ delete list.dataset.srteListDepth;
1725
+ delete list.dataset.srteListMarker;
1726
+ };
1727
+ const applyListPreset = (list, preset, depth) => {
1728
+ const definition = getSmartListPreset(preset);
1729
+ list.dataset.srteListPreset = preset;
1730
+ list.dataset.srteListDepth = String(depth);
1731
+ list.style.listStyleType = listStyleForPresetDepth(preset, depth);
1732
+ const marker = definition.markers?.[Math.min(depth, definition.markers.length - 1)];
1733
+ if (marker)
1734
+ list.dataset.srteListMarker = marker;
1735
+ else
1736
+ delete list.dataset.srteListMarker;
1737
+ };
1738
+ const applyListPresetHierarchy = (list, preset, depth = 0) => {
1739
+ applyListPreset(list, preset, depth);
1740
+ Array.from(list.children).forEach((item) => {
1741
+ if (!(item instanceof HTMLElement) || item.tagName !== "LI")
1742
+ return;
1743
+ Array.from(item.children).forEach((child) => {
1744
+ if (child instanceof HTMLElement && child.matches("ul,ol")) {
1745
+ applyListPresetHierarchy(child, preset, depth + 1);
1746
+ }
1747
+ });
1748
+ });
1749
+ };
1750
+ const defaultListStyleAtDepth = (listTag, depth, rootStyle) => {
1751
+ const styles = listStylesForRoot(listTag, rootStyle);
1752
+ return styles[Math.min(depth, styles.length - 1)];
1753
+ };
1754
+ const restyleDescendantLists = (item, listTag, depth = 1, rootStyle, preset) => {
1755
+ Array.from(item.children).forEach((child) => {
1756
+ if (!(child instanceof HTMLElement) || !child.matches("ul,ol"))
1757
+ return;
1758
+ const replacement = child.tagName.toLowerCase() === listTag
1759
+ ? child
1760
+ : cloneListShell(child, listTag);
1761
+ if (replacement !== child) {
1762
+ while (child.firstChild)
1763
+ replacement.appendChild(child.firstChild);
1764
+ child.replaceWith(replacement);
1765
+ }
1766
+ clearChecklist(replacement);
1767
+ if (preset)
1768
+ applyListPreset(replacement, preset, depth);
1769
+ else {
1770
+ clearListPreset(replacement);
1771
+ replacement.style.listStyleType = defaultListStyleAtDepth(listTag, depth, rootStyle);
1772
+ }
1773
+ Array.from(replacement.children).forEach((nestedItem) => {
1774
+ if (nestedItem instanceof HTMLElement && nestedItem.tagName === "LI") {
1775
+ restyleDescendantLists(nestedItem, listTag, depth + 1, rootStyle, preset);
1776
+ }
1777
+ });
1778
+ });
1779
+ };
1530
1780
  const getDirectSelectionCell = (range) => {
1531
1781
  const startCell = getClosestCell(range.startContainer);
1532
1782
  const endCell = getClosestCell(range.endContainer);
@@ -1588,8 +1838,15 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1588
1838
  return convertDirectTableCellSelectionToList(range, listTag);
1589
1839
  };
1590
1840
  const applyListStyle = (value) => {
1591
- const listTag = value.startsWith("ordered:") ? "ol" : "ul";
1592
- const styleType = value.replace(/^(ordered|bullet):/, "");
1841
+ if (!listFeature)
1842
+ return;
1843
+ const listTag = value.startsWith("ordered:") || value.startsWith("ordered-preset:") ? "ol" : "ul";
1844
+ const requestedPreset = value.replace(/^(ordered|bullet)-preset:/, "");
1845
+ const preset = isSmartListPreset(requestedPreset) ? requestedPreset : undefined;
1846
+ const styleType = preset
1847
+ ? listStyleForPresetDepth(preset, 0)
1848
+ : value.replace(/^(ordered|bullet):/, "");
1849
+ normalizeListNesting(editableRef.current);
1593
1850
  if (!restoreSavedSelection())
1594
1851
  safeSelectRange(getSelectionRangeInEditor());
1595
1852
  const range = getSelectionRangeInEditor();
@@ -1600,7 +1857,10 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1600
1857
  pushEditorHistory();
1601
1858
  const list = convertDirectTableCellContentsToList(currentBlock, listTag);
1602
1859
  if (list) {
1603
- list.style.listStyleType = styleType;
1860
+ if (preset)
1861
+ applyListPreset(list, preset, 0);
1862
+ else
1863
+ list.style.listStyleType = styleType;
1604
1864
  handleInput();
1605
1865
  requestAnimationFrame(updateActiveState);
1606
1866
  }
@@ -1610,7 +1870,10 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1610
1870
  pushEditorHistory();
1611
1871
  const list = convertDirectTableCellSelectionToList(range, listTag);
1612
1872
  if (list) {
1613
- list.style.listStyleType = styleType;
1873
+ if (preset)
1874
+ applyListPreset(list, preset, 0);
1875
+ else
1876
+ list.style.listStyleType = styleType;
1614
1877
  handleInput();
1615
1878
  requestAnimationFrame(updateActiveState);
1616
1879
  }
@@ -1620,8 +1883,52 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1620
1883
  const selectedItems = resolveSelectedListItems(range);
1621
1884
  const selectedPlainBlocks = selectedBlocks.filter((block) => !block.closest("ul,ol"));
1622
1885
  if (selectedItems.length > 0) {
1886
+ const outermostItems = selectedItems.filter((item) => !selectedItems.some((candidate) => candidate !== item && candidate.contains(item)));
1887
+ const commonSelectedList = resolveCommonSelectedList(selectedItems);
1888
+ const promotedLists = new Set(selectedPlainBlocks.length > 0
1889
+ ? outermostItems.map((item) => {
1890
+ let list = item.parentElement?.matches("ul,ol") ? item.parentElement : null;
1891
+ while (list?.parentElement?.closest("li")?.parentElement?.matches("ul,ol")) {
1892
+ list = list.parentElement.closest("li").parentElement;
1893
+ }
1894
+ return list;
1895
+ }).filter((list) => Boolean(list))
1896
+ : commonSelectedList ? [commonSelectedList] : []);
1623
1897
  pushEditorHistory();
1624
- const lastItem = restyleSelectedListItems(selectedItems, listTag, styleType);
1898
+ let lastItem = null;
1899
+ if (promotedLists.size > 0) {
1900
+ promotedLists.forEach((source) => {
1901
+ const items = Array.from(source.children).filter((child) => child instanceof HTMLElement && child.tagName === "LI");
1902
+ items.forEach((item) => restyleDescendantLists(item, listTag, 1, styleType, preset));
1903
+ const target = source.tagName.toLowerCase() === listTag
1904
+ ? source
1905
+ : cloneListShell(source, listTag);
1906
+ if (target !== source) {
1907
+ while (source.firstChild)
1908
+ target.appendChild(source.firstChild);
1909
+ source.replaceWith(target);
1910
+ }
1911
+ clearChecklist(target);
1912
+ if (preset)
1913
+ applyListPreset(target, preset, 0);
1914
+ else {
1915
+ clearListPreset(target);
1916
+ target.style.listStyleType = styleType;
1917
+ }
1918
+ lastItem = target.lastElementChild;
1919
+ mergeAdjacentCompatibleLists(target);
1920
+ });
1921
+ }
1922
+ const scopedItems = outermostItems.filter((item) => !Array.from(promotedLists).some((list) => list.contains(item)));
1923
+ scopedItems.forEach((item) => restyleDescendantLists(item, listTag, 1, styleType, preset));
1924
+ lastItem = restyleSelectedListItems(scopedItems, listTag, styleType) || lastItem;
1925
+ if (preset) {
1926
+ scopedItems.forEach((item) => {
1927
+ const list = item.parentElement;
1928
+ if (list?.matches("ul,ol"))
1929
+ applyListPreset(list, preset, 0);
1930
+ });
1931
+ }
1625
1932
  const convertedPlainBlocks = convertSelectedBlocksToList(selectedPlainBlocks, listTag, styleType);
1626
1933
  if (!convertedPlainBlocks && lastItem)
1627
1934
  focusElementEnd(lastItem);
@@ -1655,7 +1962,12 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1655
1962
  if (convertSelectedBlocksToList(convertibleBlocks, listTag)) {
1656
1963
  const createdList = getCurrentBlock()?.closest("ul,ol");
1657
1964
  if (createdList) {
1658
- createdList.style.listStyleType = styleType;
1965
+ if (preset)
1966
+ applyListPresetHierarchy(createdList, preset);
1967
+ else {
1968
+ clearListPreset(createdList);
1969
+ createdList.style.listStyleType = styleType;
1970
+ }
1659
1971
  const mergedList = mergeAdjacentCompatibleLists(createdList);
1660
1972
  const lastItem = mergedList.lastElementChild;
1661
1973
  if (lastItem)
@@ -1670,7 +1982,12 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1670
1982
  toggleList(listTag);
1671
1983
  const createdList = getCurrentBlock()?.closest("ul,ol");
1672
1984
  if (createdList) {
1673
- createdList.style.listStyleType = styleType;
1985
+ if (preset)
1986
+ applyListPresetHierarchy(createdList, preset);
1987
+ else {
1988
+ clearListPreset(createdList);
1989
+ createdList.style.listStyleType = styleType;
1990
+ }
1674
1991
  handleInput();
1675
1992
  }
1676
1993
  return;
@@ -1696,7 +2013,12 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1696
2013
  list.parentElement?.replaceChild(target, list);
1697
2014
  }
1698
2015
  clearChecklist(target);
1699
- target.style.listStyleType = styleType;
2016
+ if (preset)
2017
+ applyListPresetHierarchy(target, preset);
2018
+ else {
2019
+ clearListPreset(target);
2020
+ target.style.listStyleType = styleType;
2021
+ }
1700
2022
  lastList = target;
1701
2023
  styledLists.push(target);
1702
2024
  });
@@ -1712,6 +2034,8 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1712
2034
  requestAnimationFrame(updateActiveState);
1713
2035
  };
1714
2036
  const applyChecklist = (strikeCompleted = false, toggleOff = false) => {
2037
+ if (!checklistFeature)
2038
+ return;
1715
2039
  if (!restoreSavedSelection())
1716
2040
  safeSelectRange(getSelectionRangeInEditor());
1717
2041
  const range = getSelectionRangeInEditor();
@@ -1719,6 +2043,32 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1719
2043
  return;
1720
2044
  const selectedItems = resolveSelectedListItems(range);
1721
2045
  const selectedChecklists = selectedItems.filter((item) => item.parentElement?.dataset.srteChecklist === "true");
2046
+ const selectedLists = new Set(selectedItems.map((item) => item.parentElement).filter(Boolean));
2047
+ const coversCompleteList = selectedLists.size === 1 &&
2048
+ selectedItems.length === (Array.from(selectedLists)[0]?.children.length || 0);
2049
+ const removingActiveChecklist = toggleOff &&
2050
+ selectedItems.length > 0 &&
2051
+ selectedChecklists.length === selectedItems.length;
2052
+ if (!removingActiveChecklist && (selectedItems.length === 0 || coversCompleteList)) {
2053
+ const editor = editableRef.current;
2054
+ const beforeCoreHtml = editor?.innerHTML || "";
2055
+ const coreResult = editor
2056
+ ? editorControllerRef.current
2057
+ .bindRoot(editor)
2058
+ .execute("checklist.toggle", { strikeCompleted })
2059
+ : null;
2060
+ if (coreResult) {
2061
+ inputHistoryGroupRef.current = null;
2062
+ editorControllerRef.current.bindRoot(editor).recordHistorySnapshot(beforeCoreHtml);
2063
+ syncChecklistControls(editor);
2064
+ const selection = window.getSelection();
2065
+ if (selection?.rangeCount)
2066
+ savedRangeRef.current = selection.getRangeAt(0).cloneRange();
2067
+ handleInput();
2068
+ requestAnimationFrame(updateActiveState);
2069
+ return;
2070
+ }
2071
+ }
1722
2072
  if (toggleOff && selectedItems.length > 0 && selectedChecklists.length === selectedItems.length) {
1723
2073
  pushEditorHistory();
1724
2074
  selectedItems.forEach((item) => {
@@ -1936,9 +2286,12 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1936
2286
  return changed;
1937
2287
  };
1938
2288
  const toggleList = (listTag) => {
2289
+ if (!listFeature)
2290
+ return;
1939
2291
  const editor = editableRef.current;
1940
2292
  if (!editor)
1941
2293
  return;
2294
+ normalizeListNesting(editor);
1942
2295
  if (!restoreSavedSelection())
1943
2296
  safeSelectRange(getSelectionRangeInEditor());
1944
2297
  const setListActiveState = (active) => {
@@ -1962,10 +2315,41 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1962
2315
  const blocks = getSelectedBlocks(range);
1963
2316
  const selectedListItems = resolveSelectedListItems(range);
1964
2317
  if (selectedListItems.length > 0) {
2318
+ const selectedPlainBlocks = blocks.filter((block) => !block.closest("ul,ol"));
2319
+ const commonSelectedList = resolveCommonSelectedList(selectedListItems);
2320
+ let outermostItems = selectedListItems.filter((item) => !selectedListItems.some((candidate) => candidate !== item && candidate.contains(item)));
1965
2321
  pushEditorHistory();
1966
- if (transformSelectedListItems(selectedListItems, listTag)) {
1967
- const active = Boolean(getCurrentBlock()?.closest(listTag));
1968
- setListActiveState(active);
2322
+ const togglingOff = selectedPlainBlocks.length === 0 &&
2323
+ outermostItems.every((item) => item.parentElement?.tagName.toLowerCase() === listTag);
2324
+ if (togglingOff) {
2325
+ if (transformSelectedListItems(outermostItems, listTag)) {
2326
+ setListActiveState(false);
2327
+ handleInput();
2328
+ requestAnimationFrame(updateActiveState);
2329
+ return;
2330
+ }
2331
+ }
2332
+ else {
2333
+ const containingLists = new Set(selectedPlainBlocks.length === 0 && commonSelectedList
2334
+ ? [commonSelectedList]
2335
+ : outermostItems.map((item) => {
2336
+ let list = item.parentElement?.matches("ul,ol") ? item.parentElement : null;
2337
+ if (selectedPlainBlocks.length > 0) {
2338
+ while (list?.parentElement?.closest("li")?.parentElement?.matches("ul,ol")) {
2339
+ list = list.parentElement.closest("li").parentElement;
2340
+ }
2341
+ }
2342
+ return list;
2343
+ }));
2344
+ outermostItems = Array.from(containingLists).flatMap((list) => list
2345
+ ? Array.from(list.children).filter((child) => child instanceof HTMLElement && child.tagName === "LI")
2346
+ : []);
2347
+ outermostItems.forEach((item) => restyleDescendantLists(item, listTag));
2348
+ const lastItem = restyleSelectedListItems(outermostItems, listTag, defaultListStyleAtDepth(listTag, 0));
2349
+ const convertedPlainBlocks = convertSelectedBlocksToList(selectedPlainBlocks, listTag, defaultListStyleAtDepth(listTag, 0));
2350
+ if (!convertedPlainBlocks && lastItem)
2351
+ focusElementEnd(lastItem);
2352
+ setListActiveState(true);
1969
2353
  handleInput();
1970
2354
  requestAnimationFrame(updateActiveState);
1971
2355
  return;
@@ -2061,6 +2445,8 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2061
2445
  catch { }
2062
2446
  };
2063
2447
  const toggleBlockquote = () => {
2448
+ if (!blockquoteFeature)
2449
+ return;
2064
2450
  try {
2065
2451
  if (!restoreSavedSelection())
2066
2452
  safeSelectRange(getSelectionRangeInEditor());
@@ -2068,6 +2454,40 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2068
2454
  const editor = editableRef.current;
2069
2455
  if (!editor || !range)
2070
2456
  return;
2457
+ const rangeNode = range.commonAncestorContainer instanceof HTMLElement
2458
+ ? range.commonAncestorContainer
2459
+ : range.commonAncestorContainer.parentElement;
2460
+ const activeQuote = rangeNode?.closest("blockquote");
2461
+ if (activeQuote && editor.contains(activeQuote)) {
2462
+ const beforeCoreHtml = editor.innerHTML;
2463
+ pushEditorHistory();
2464
+ const replacements = editorControllerRef.current.bindRoot(editor).executeBlockCommand([activeQuote], { id: "blockquote.toggle" });
2465
+ if (replacements) {
2466
+ focusElementEnd(replacements[replacements.length - 1]);
2467
+ handleInput();
2468
+ requestAnimationFrame(updateActiveState);
2469
+ return;
2470
+ }
2471
+ editorControllerRef.current.discardLastHistorySnapshot(beforeCoreHtml);
2472
+ }
2473
+ else {
2474
+ const candidates = (range.collapsed
2475
+ ? [getCurrentBlock()].filter((block) => Boolean(block))
2476
+ : getSelectedBlocks(range)).filter((block) => editor.contains(block));
2477
+ const coreCandidates = candidates.filter((block) => block.matches("p,h1,h2,h3,h4,h5,h6,pre") && !block.closest("li,td,th"));
2478
+ if (coreCandidates.length === candidates.length && coreCandidates.length > 0) {
2479
+ const beforeCoreHtml = editor.innerHTML;
2480
+ pushEditorHistory();
2481
+ const replacements = editorControllerRef.current.bindRoot(editor).executeBlockCommand(coreCandidates, { id: "blockquote.toggle" });
2482
+ if (replacements) {
2483
+ focusElementEnd(replacements[replacements.length - 1]);
2484
+ handleInput();
2485
+ requestAnimationFrame(updateActiveState);
2486
+ return;
2487
+ }
2488
+ editorControllerRef.current.discardLastHistorySnapshot(beforeCoreHtml);
2489
+ }
2490
+ }
2071
2491
  const unwrapQuote = (quote) => {
2072
2492
  const parent = quote.parentElement;
2073
2493
  if (!parent)
@@ -2209,6 +2629,8 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2209
2629
  catch { }
2210
2630
  };
2211
2631
  const toggleCodeBlock = () => {
2632
+ if (!codeBlockFeature)
2633
+ return;
2212
2634
  try {
2213
2635
  if (!restoreSavedSelection())
2214
2636
  safeSelectRange(getSelectionRangeInEditor());
@@ -2223,10 +2645,27 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2223
2645
  return;
2224
2646
  const listItems = resolveSelectedListItems(range);
2225
2647
  const plainBlocks = blocks.filter((block) => !block.closest("ul,ol") && !block.closest("table"));
2226
- const getItemCode = (item) => item.querySelector(":scope > pre[data-srte-list-code]");
2648
+ // Detect by tag alone, not the data-srte-list-code marker: operations
2649
+ // that round-trip a list item's content through the canonical core
2650
+ // model (e.g. unwrapping an enclosing blockquote) don't know about
2651
+ // this editor-internal attribute and drop it, which previously made
2652
+ // an already-coded item look uncoded and get double-wrapped.
2653
+ const getItemCode = (item) => item.querySelector(":scope > pre");
2227
2654
  const allTargetsActive = listItems.every((item) => Boolean(getItemCode(item))) &&
2228
2655
  plainBlocks.every((block) => block.tagName === "PRE") &&
2229
2656
  listItems.length + plainBlocks.length > 0;
2657
+ if (listItems.length === 0 && plainBlocks.length === blocks.length && plainBlocks.length > 0) {
2658
+ const beforeCoreHtml = editor.innerHTML;
2659
+ pushEditorHistory();
2660
+ const replacements = editorControllerRef.current.bindRoot(editor).executeBlockCommand(plainBlocks, { id: "code-block.toggle" });
2661
+ if (replacements) {
2662
+ focusElementEnd(replacements[replacements.length - 1]);
2663
+ handleInput();
2664
+ requestAnimationFrame(updateActiveState);
2665
+ return;
2666
+ }
2667
+ editorControllerRef.current.discardLastHistorySnapshot(beforeCoreHtml);
2668
+ }
2230
2669
  pushEditorHistory();
2231
2670
  let lastTarget = null;
2232
2671
  listItems.forEach((item) => {
@@ -2234,8 +2673,15 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2234
2673
  if (allTargetsActive && existing) {
2235
2674
  const code = existing.querySelector(":scope > code");
2236
2675
  const source = code || existing;
2676
+ // <code> only ever holds inline content (see creation below), so
2677
+ // restore it inside a single paragraph rather than splicing bare
2678
+ // inline nodes directly into the list item.
2679
+ const paragraph = document.createElement("p");
2237
2680
  while (source.firstChild)
2238
- item.insertBefore(source.firstChild, existing);
2681
+ paragraph.appendChild(source.firstChild);
2682
+ if (!paragraph.childNodes.length)
2683
+ paragraph.innerHTML = "<br>";
2684
+ item.insertBefore(paragraph, existing);
2239
2685
  existing.remove();
2240
2686
  lastTarget = item;
2241
2687
  return;
@@ -2249,12 +2695,21 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2249
2695
  pre.style.textAlign = "left";
2250
2696
  const code = document.createElement("code");
2251
2697
  const nestedList = Array.from(item.children).find((child) => child.matches("ul,ol")) || null;
2252
- Array.from(item.childNodes).forEach((node) => {
2253
- if (node === nestedList)
2254
- return;
2255
- if (node instanceof HTMLElement && node.dataset.srteCheck === "true")
2256
- return;
2257
- code.appendChild(node);
2698
+ const contentNodes = Array.from(item.childNodes).filter((node) => node !== nestedList && !(node instanceof HTMLElement && node.dataset.srteCheck === "true"));
2699
+ // <code> can only hold inline content: a p/heading child (the usual
2700
+ // shape for list-item content) is unwrapped into its inline nodes
2701
+ // rather than nested whole, which would be invalid markup.
2702
+ contentNodes.forEach((node, index) => {
2703
+ if (node instanceof HTMLElement && node.matches("p,h1,h2,h3,h4,h5,h6")) {
2704
+ while (node.firstChild)
2705
+ code.appendChild(node.firstChild);
2706
+ node.remove();
2707
+ }
2708
+ else {
2709
+ code.appendChild(node);
2710
+ }
2711
+ if (index < contentNodes.length - 1)
2712
+ code.appendChild(document.createElement("br"));
2258
2713
  });
2259
2714
  if (!code.childNodes.length)
2260
2715
  code.innerHTML = "<br>";
@@ -2813,6 +3268,33 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2813
3268
  range.collapse(false);
2814
3269
  safeSelectRange(range);
2815
3270
  }
3271
+ const coreImage = editorControllerRef.current.bindRoot(host).insertInlineImage({
3272
+ src,
3273
+ alt: typeof srcOrItem === "string" ? "image" : (srcOrItem.alt || srcOrItem.title || "image"),
3274
+ ...(typeof srcOrItem !== "string" && srcOrItem.title ? { title: srcOrItem.title } : {}),
3275
+ });
3276
+ if (coreImage) {
3277
+ coreImage.draggable = true;
3278
+ coreImage.style.maxWidth = "100%";
3279
+ coreImage.style.height = "auto";
3280
+ coreImage.style.display = "inline-block";
3281
+ if (typeof srcOrItem !== "string") {
3282
+ if (srcOrItem.license?.author)
3283
+ coreImage.dataset.licenseAuthor = srcOrItem.license.author;
3284
+ if (srcOrItem.license?.licenseType)
3285
+ coreImage.dataset.licenseType = srcOrItem.license.licenseType;
3286
+ if (srcOrItem.license?.licenseText)
3287
+ coreImage.dataset.licenseText = srcOrItem.license.licenseText;
3288
+ if (srcOrItem.license?.sourceUrl)
3289
+ coreImage.dataset.licenseUrl = srcOrItem.license.sourceUrl;
3290
+ if (srcOrItem.license?.workName)
3291
+ coreImage.dataset.workName = srcOrItem.license.workName;
3292
+ }
3293
+ setSelectedImage(coreImage);
3294
+ scheduleImageOverlay();
3295
+ handleInput();
3296
+ return;
3297
+ }
2816
3298
  const img = document.createElement("img");
2817
3299
  img.src = src;
2818
3300
  img.draggable = true;
@@ -2882,27 +3364,47 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2882
3364
  safeSelectRange(range);
2883
3365
  }
2884
3366
  pushEditorHistory();
2885
- const span = document.createElement("span");
2886
- span.setAttribute("data-formula", tex);
3367
+ const span = editorControllerRef.current.bindRoot(host).insertFormula({
3368
+ value: tex,
3369
+ displayText: `$${tex}$`,
3370
+ });
3371
+ if (span) {
3372
+ try {
3373
+ const katex = window.katex;
3374
+ if (katex && typeof katex.render === "function") {
3375
+ katex.render(tex, span, { throwOnError: false });
3376
+ }
3377
+ }
3378
+ catch { }
3379
+ const next = document.createRange();
3380
+ next.setStartAfter(span);
3381
+ next.collapse(true);
3382
+ safeSelectRange(next);
3383
+ savedRangeRef.current = next.cloneRange();
3384
+ handleInput();
3385
+ return;
3386
+ }
3387
+ const fallbackSpan = document.createElement("span");
3388
+ fallbackSpan.setAttribute("data-formula", tex);
2887
3389
  try {
2888
3390
  // @ts-ignore
2889
3391
  const katex = window.katex;
2890
3392
  if (katex && typeof katex.render === "function") {
2891
- katex.render(tex, span, { throwOnError: false });
3393
+ katex.render(tex, fallbackSpan, { throwOnError: false });
2892
3394
  }
2893
3395
  else {
2894
- span.textContent = `$${tex}$`;
3396
+ fallbackSpan.textContent = `$${tex}$`;
2895
3397
  }
2896
3398
  }
2897
3399
  catch {
2898
- span.textContent = `$${tex}$`;
3400
+ fallbackSpan.textContent = `$${tex}$`;
2899
3401
  }
2900
3402
  if (range)
2901
- range.insertNode(span);
3403
+ range.insertNode(fallbackSpan);
2902
3404
  else
2903
- host.appendChild(span);
3405
+ host.appendChild(fallbackSpan);
2904
3406
  const r = document.createRange();
2905
- r.setStartAfter(span);
3407
+ r.setStartAfter(fallbackSpan);
2906
3408
  r.collapse(true);
2907
3409
  safeSelectRange(r);
2908
3410
  handleInput();
@@ -2977,864 +3479,84 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2977
3479
  });
2978
3480
  }
2979
3481
  };
2980
- const handlePdfFiles = (files) => {
2981
- if (!files || files.length === 0)
2982
- return;
2983
- const file = files[0];
2984
- if (file.type !== 'application/pdf')
2985
- return;
2986
- // Check if editor has content
2987
- const el = editableRef.current;
2988
- const hasContent = el && el.textContent && el.textContent.trim().length > 0;
2989
- if (hasContent) {
2990
- setPendingImport({ file, type: 'pdf' });
2991
- }
2992
- else {
2993
- processImport(file, 'pdf', 'replace');
2994
- }
3482
+ const getExportableDocument = () => {
3483
+ return editorControllerRef.current
3484
+ .bindRoot(editableRef.current)
3485
+ .getDocument();
2995
3486
  };
2996
- const processImport = async (file, type, mode) => {
2997
- if (type === 'pdf') {
2998
- await processPdf(file, mode);
2999
- }
3000
- else {
3001
- await processDocx(file, mode);
3002
- }
3003
- setPendingImport(null);
3004
- // Reset inputs
3005
- if (pdfInputRef.current)
3006
- pdfInputRef.current.value = "";
3007
- if (docxInputRef.current)
3008
- docxInputRef.current.value = "";
3487
+ const downloadFormatResult = (result) => {
3488
+ if (result.kind === "handled")
3489
+ return;
3490
+ const blob = result.kind === "blob"
3491
+ ? result.content
3492
+ : new Blob([result.content], { type: result.mediaType });
3493
+ const url = URL.createObjectURL(blob);
3494
+ const link = document.createElement("a");
3495
+ link.href = url;
3496
+ link.download = result.filename;
3497
+ document.body.appendChild(link);
3498
+ link.click();
3499
+ link.remove();
3500
+ URL.revokeObjectURL(url);
3009
3501
  };
3010
- const processPdf = async (file, mode) => {
3011
- try {
3502
+ const processFormatImport = async (definition, file, mode) => {
3503
+ if (!definition.importFile)
3504
+ return;
3505
+ if (definition.id === "pdf")
3012
3506
  setLoadingPdf(true);
3013
- const arrayBuffer = await file.arrayBuffer();
3014
- const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
3015
- let fullHtml = '';
3016
- for (let i = 1; i <= pdf.numPages; i++) {
3017
- const page = await pdf.getPage(i);
3018
- const viewport = page.getViewport({ scale: 1 });
3019
- const textContent = await page.getTextContent();
3020
- const styles = textContent.styles;
3021
- // 1. Group items into lines
3022
- const items = textContent.items;
3023
- // Calculate base statistics
3024
- const heights = items.map(item => Math.abs(item.transform[3])).filter(h => h > 0);
3025
- heights.sort((a, b) => a - b);
3026
- const medianHeight = heights[Math.floor(heights.length / 2)] || 12;
3027
- // Group by Y (with tolerance)
3028
- const linesMap = new Map();
3029
- for (const item of items) {
3030
- if (!item.str.trim())
3031
- continue;
3032
- // Normalize Y to integer buckets to group roughly
3033
- // PDF Y is bottom-0, so higher Y is higher on page.
3034
- const y = item.transform[5];
3035
- // Find closest existing line
3036
- let foundKey = -1;
3037
- for (const key of linesMap.keys()) {
3038
- if (Math.abs(key - y) < medianHeight * 0.5) {
3039
- foundKey = key;
3040
- break;
3041
- }
3042
- }
3043
- if (foundKey !== -1) {
3044
- linesMap.get(foundKey).items.push(item);
3045
- }
3046
- else {
3047
- linesMap.set(y, { y, items: [item] });
3048
- }
3049
- }
3050
- // Convert map to sorted array (top to bottom)
3051
- const lines = Array.from(linesMap.values()).sort((a, b) => b.y - a.y);
3052
- // Sort items within lines (left to right)
3053
- lines.forEach(line => {
3054
- line.items.sort((a, b) => a.transform[4] - b.transform[4]);
3055
- });
3056
- // 2. Identify and Build Structures
3057
- let html = '';
3058
- let listStack = []; // 'ul' or 'ol'
3059
- let inTable = false;
3060
- let tableColumns = []; // X-coordinates of column starts
3061
- let tableHtml = '';
3062
- const closeList = () => {
3063
- if (listStack.length > 0) {
3064
- html += `</${listStack.pop()}>`;
3065
- }
3066
- };
3067
- const closeTable = () => {
3068
- if (inTable) {
3069
- html += '<div data-table-wrapper="true" style="overflow-x:auto;width:100%;"><table style="border-collapse:collapse;width:100%;" border="1"><tbody>' + tableHtml + '</tbody></table></div>';
3070
- tableHtml = '';
3071
- inTable = false;
3072
- tableColumns = [];
3073
- }
3074
- };
3075
- for (let lIndex = 0; lIndex < lines.length; lIndex++) {
3076
- const line = lines[lIndex];
3077
- // Calculate gaps and text
3078
- let lineText = '';
3079
- let lineHtmlContent = '';
3080
- let lastX = -1;
3081
- let gaps = [];
3082
- let itemXs = []; // Start X of logical items (words or phrases)
3083
- // Reconstruct text with spacing detection
3084
- for (let j = 0; j < line.items.length; j++) {
3085
- const item = line.items[j];
3086
- const x = item.transform[4];
3087
- const width = item.width;
3088
- const fontName = item.fontName;
3089
- const fontObj = styles[fontName];
3090
- const fontFamily = fontObj?.fontFamily?.toLowerCase() || '';
3091
- const isBold = fontFamily.includes('bold') || false;
3092
- const isItalic = fontFamily.includes('italic') || fontFamily.includes('oblique');
3093
- const fontSize = Math.max(8, Math.round(Math.abs(item.transform[3])));
3094
- if (lastX > 0) {
3095
- const gap = x - lastX;
3096
- if (gap > 2) { // Minimal space threshold
3097
- lineText += ' ';
3098
- lineHtmlContent += ' ';
3099
- if (gap > 20) { // Large gap threshold for table detection
3100
- gaps.push(gap);
3101
- }
3102
- }
3103
- }
3104
- else {
3105
- // First item
3106
- }
3107
- // Track "columns" candidates: items separated by big gaps
3108
- if (j === 0 || (x - lastX) > 20) {
3109
- itemXs.push(x);
3110
- }
3111
- // Append text style
3112
- const chunkStyle = cssRules([
3113
- ['font-size', `${fontSize}px`],
3114
- ['font-weight', isBold ? '700' : ''],
3115
- ['font-style', isItalic ? 'italic' : ''],
3116
- ]);
3117
- let chunk = `<span${styleAttr(chunkStyle)}>${escapeHtml(item.str)}</span>`;
3118
- lineText += item.str;
3119
- lineHtmlContent += chunk;
3120
- lastX = x + width;
3121
- }
3122
- // === Structure Detection ===
3123
- // Max Font Size in line
3124
- const maxH = Math.max(...line.items.map((i) => Math.abs(i.transform[3])));
3125
- const isHeader = maxH > medianHeight * 1.2;
3126
- // List Detection
3127
- const isBullet = /^[•\-\*]\s/.test(lineText);
3128
- const isNumber = /^\d+[\.\)]\s/.test(lineText);
3129
- // Table Detection Logic
3130
- // A line starts a table if it has distinct "columns" (multiple items with large gaps)
3131
- // Or if we are already in a table and this line aligns with columns
3132
- let isTableLine = false;
3133
- // If in table, check alignment
3134
- if (inTable) {
3135
- // Check if items align with known columns
3136
- // Simple loose check: do any of the itemXs align with tableColumns?
3137
- // Or is the line just sparsely populated but roughly compatible?
3138
- // We'll continually simple-add rows for now until a Paragraph break (plain text, no gaps) is found.
3139
- // If line looks like normal paragraph (no large gaps, starts at left margin), close table
3140
- const isPlainParagraph = gaps.length === 0 && itemXs[0] < 50 && lineText.length > 50;
3141
- // Allow wrapping text in table cells, which might look like lines with no gaps?
3142
- // Table wrapping usually is indented or aligns with a column > 0.
3143
- const alignsWithColumn = itemXs.some(x => tableColumns.some(cx => Math.abs(x - cx) < 20));
3144
- if (alignsWithColumn || (itemXs[0] > 50)) {
3145
- isTableLine = true;
3146
- }
3147
- else {
3148
- // Maybe a new row starting at col 0?
3149
- // If it aligns with col 0.
3150
- if (Math.abs(itemXs[0] - tableColumns[0]) < 20) {
3151
- isTableLine = true;
3152
- }
3153
- }
3154
- }
3155
- else {
3156
- // Potential start of table: multiple items separated by gaps, AND next line likely follows suit?
3157
- // Or simply: It has > 1 column significantly spaced.
3158
- if (itemXs.length >= 2 && gaps.some(g => g > 30)) {
3159
- isTableLine = true;
3160
- // Establish columns
3161
- tableColumns = [...itemXs];
3162
- }
3163
- }
3164
- // --- Apply Logic ---
3165
- if (isTableLine) {
3166
- closeList();
3167
- if (!inTable) {
3168
- inTable = true;
3169
- // Start table
3170
- }
3171
- // Build Row
3172
- // We need to map items to cells based on tableColumns.
3173
- // Naive approach: Items close to col X go to col X.
3174
- let rowHtml = '<tr>';
3175
- // We assume `tableColumns` defines the start of each cell.
3176
- // We create a cell for each column.
3177
- // Collect content for each bucket.
3178
- const cellContents = new Array(tableColumns.length).fill('');
3179
- let currentItemHtml = '';
3180
- let currentItemStart = -1;
3181
- // process items again to slot them
3182
- let currentLineX = 0;
3183
- for (const item of line.items) {
3184
- const x = item.transform[4];
3185
- const w = item.width;
3186
- const txt = item.str;
3187
- const fontObj = styles[item.fontName];
3188
- const fontFamily = fontObj?.fontFamily?.toLowerCase() || '';
3189
- const isBold = fontFamily.includes('bold');
3190
- const isItalic = fontFamily.includes('italic') || fontFamily.includes('oblique');
3191
- const fontSize = Math.max(8, Math.round(Math.abs(item.transform[3])));
3192
- const styledTxt = `<span${styleAttr(cssRules([
3193
- ['font-size', `${fontSize}px`],
3194
- ['font-weight', isBold ? '700' : ''],
3195
- ['font-style', isItalic ? 'italic' : ''],
3196
- ]))}>${escapeHtml(txt)}</span>`;
3197
- // Decide which column this belongs to
3198
- // Find closest column to the left (or close enough)
3199
- let colIdx = 0;
3200
- let minDiff = 9999;
3201
- for (let c = 0; c < tableColumns.length; c++) {
3202
- const colX = tableColumns[c];
3203
- // If item starts near colX or after it (but before next col)
3204
- // Actually, just find the "controlling" column (closest start to the left)
3205
- if (x >= colX - 10) {
3206
- colIdx = c;
3207
- }
3208
- }
3209
- // Append space if needed
3210
- if (cellContents[colIdx])
3211
- cellContents[colIdx] += ' ';
3212
- cellContents[colIdx] += styledTxt;
3213
- }
3214
- cellContents.forEach(content => {
3215
- rowHtml += `<td style="border:1px solid #d1d5db;padding:8px;vertical-align:top;">${content || '&nbsp;'}</td>`;
3216
- });
3217
- rowHtml += '</tr>';
3218
- tableHtml += rowHtml;
3219
- }
3220
- else {
3221
- closeTable();
3222
- if (isBullet || isNumber) {
3223
- const listType = isBullet ? 'ul' : 'ol';
3224
- if (listStack.length === 0 || listStack[listStack.length - 1] !== listType) {
3225
- if (listStack.length > 0)
3226
- closeList(); // Close switch
3227
- html += `<${listType}>`;
3228
- listStack.push(listType);
3229
- }
3230
- // Strip marker
3231
- const content = lineHtmlContent.replace(/^[•\-\*]|\d+[\.\)]/, '').trim();
3232
- html += `<li>${content}</li>`;
3233
- }
3234
- else {
3235
- closeList();
3236
- if (isHeader) {
3237
- const tag = maxH > medianHeight * 1.5 ? 'h2' : 'h3';
3238
- const firstX = line.items[0]?.transform?.[4] || 0;
3239
- const lastItem = line.items[line.items.length - 1];
3240
- const lastRight = lastItem ? lastItem.transform[4] + lastItem.width : firstX;
3241
- const center = (firstX + lastRight) / 2;
3242
- const align = Math.abs(center - viewport.width / 2) < viewport.width * 0.12 ? 'center' : firstX > viewport.width * 0.55 ? 'right' : '';
3243
- html += `<${tag}${styleAttr(cssRules([['text-align', align]]))}>${lineHtmlContent}</${tag}>`;
3244
- }
3245
- else {
3246
- const firstX = line.items[0]?.transform?.[4] || 0;
3247
- const lastItem = line.items[line.items.length - 1];
3248
- const lastRight = lastItem ? lastItem.transform[4] + lastItem.width : firstX;
3249
- const center = (firstX + lastRight) / 2;
3250
- const align = Math.abs(center - viewport.width / 2) < viewport.width * 0.12 ? 'center' : firstX > viewport.width * 0.55 ? 'right' : '';
3251
- html += `<p${styleAttr(cssRules([
3252
- ['text-align', align],
3253
- ['margin-left', firstX > 40 && !align ? `${Math.round(firstX)}px` : ''],
3254
- ]))}>${lineHtmlContent}</p>`;
3255
- }
3256
- }
3257
- }
3258
- }
3259
- closeList();
3260
- closeTable();
3261
- fullHtml += html;
3262
- }
3263
- insertImportedHtml(fullHtml, mode, { preserveColors: true, preserveDocumentLayout: true });
3507
+ if (definition.id === "docx")
3508
+ setLoadingDocx(true);
3509
+ try {
3510
+ const imported = await definition.importFile(file, { ownerDocument: document });
3511
+ insertImportedHtml(imported.layoutHtml || serializeSmartDocument(imported.document), mode, {
3512
+ preserveColors: imported.preserveColors ?? true,
3513
+ preserveDocumentLayout: imported.preserveDocumentLayout ?? true,
3514
+ });
3264
3515
  }
3265
3516
  catch (error) {
3266
- console.error('Error reading PDF:', error);
3267
- // Optional: show user error
3517
+ console.error(`Error importing ${definition.label}:`, error);
3268
3518
  }
3269
3519
  finally {
3270
- setLoadingPdf(false);
3520
+ if (definition.id === "pdf")
3521
+ setLoadingPdf(false);
3522
+ if (definition.id === "docx")
3523
+ setLoadingDocx(false);
3524
+ setPendingImport(null);
3271
3525
  }
3272
3526
  };
3273
- const handleDocxFiles = (files) => {
3274
- if (!files || files.length === 0)
3527
+ const openFormatImport = (formatId) => {
3528
+ const definition = formatRuntime.get(formatId);
3529
+ if (!definition?.importFile || !formatRuntime.canImport(formatId))
3530
+ return;
3531
+ const input = document.createElement("input");
3532
+ input.type = "file";
3533
+ input.accept = definition.accept || `.${definition.extension}`;
3534
+ input.onchange = () => {
3535
+ const file = input.files?.[0];
3536
+ if (!file)
3537
+ return;
3538
+ const hasContent = Boolean(editableRef.current?.textContent?.trim());
3539
+ if (hasContent && definition.confirmImportWhenNotEmpty) {
3540
+ setPendingImport({ file, definition });
3541
+ return;
3542
+ }
3543
+ void processFormatImport(definition, file, hasContent ? "append" : "replace");
3544
+ };
3545
+ input.click();
3546
+ };
3547
+ const runFormatExport = async (formatId) => {
3548
+ const definition = formatRuntime.get(formatId);
3549
+ if (!definition || !formatRuntime.canExport(formatId))
3275
3550
  return;
3276
- const file = files[0];
3277
- if (!file.name.endsWith('.docx'))
3551
+ if (!definition.exportDocument)
3278
3552
  return;
3279
- // Check if editor has content
3280
- const el = editableRef.current;
3281
- const hasContent = el && el.textContent && el.textContent.trim().length > 0;
3282
- if (hasContent) {
3283
- setPendingImport({ file, type: 'docx' });
3284
- }
3285
- else {
3286
- processImport(file, 'docx', 'replace');
3287
- }
3553
+ const result = await definition.exportDocument(getExportableDocument(), {
3554
+ ownerDocument: document,
3555
+ hostWindow: window,
3556
+ });
3557
+ downloadFormatResult(result);
3288
3558
  };
3289
- const processDocx = async (file, mode) => {
3290
- try {
3291
- setLoadingDocx(true);
3292
- const arrayBuffer = await file.arrayBuffer();
3293
- const html = preserveDocxStyles
3294
- ? await convertDocxToStyledHtml(arrayBuffer)
3295
- : await convertDocxWithMammoth(arrayBuffer);
3296
- if (html) {
3297
- insertImportedHtml(`<div class="srte-preserve-colors">${html}</div>`, mode, {
3298
- preserveColors: preserveDocxStyles,
3299
- preserveDocumentLayout: preserveDocxStyles,
3300
- });
3301
- }
3302
- }
3303
- catch (error) {
3304
- console.error('Error reading DOCX:', error);
3305
- }
3306
- finally {
3307
- setLoadingDocx(false);
3308
- }
3309
- };
3310
- const convertDocxWithMammoth = async (arrayBuffer) => {
3311
- const result = await mammoth.convertToHtml({ arrayBuffer });
3312
- const temp = document.createElement('div');
3313
- temp.innerHTML = result.value;
3314
- enhanceImportedTables(temp);
3315
- return temp.innerHTML;
3316
- };
3317
- const convertDocxToStyledHtml = async (arrayBuffer) => {
3318
- try {
3319
- const zip = await JSZip.loadAsync(arrayBuffer);
3320
- const documentXml = await zip.file('word/document.xml')?.async('text');
3321
- if (!documentXml)
3322
- return convertDocxWithMammoth(arrayBuffer);
3323
- const parser = new DOMParser();
3324
- const doc = parser.parseFromString(documentXml, 'application/xml');
3325
- if (doc.querySelector('parsererror'))
3326
- return convertDocxWithMammoth(arrayBuffer);
3327
- const body = Array.from(doc.getElementsByTagName('*')).find((node) => node.localName === 'body');
3328
- if (!body)
3329
- return convertDocxWithMammoth(arrayBuffer);
3330
- const html = directChildren(body)
3331
- .filter((node) => node.localName !== 'sectPr')
3332
- .map((node) => {
3333
- if (node.localName === 'p')
3334
- return convertDocxParagraph(node);
3335
- if (node.localName === 'tbl')
3336
- return convertDocxTable(node);
3337
- return '';
3338
- })
3339
- .join('');
3340
- if (!html.trim())
3341
- return convertDocxWithMammoth(arrayBuffer);
3342
- const temp = document.createElement('div');
3343
- temp.innerHTML = html;
3344
- enhanceImportedTables(temp);
3345
- return temp.innerHTML;
3346
- }
3347
- catch (error) {
3348
- console.warn('Falling back to Mammoth DOCX import:', error);
3349
- return convertDocxWithMammoth(arrayBuffer);
3350
- }
3351
- };
3352
- const directChildren = (node) => Array.from(node.children);
3353
- const firstChildByName = (node, localName) => node
3354
- ? directChildren(node).find((child) => child.localName === localName)
3355
- : undefined;
3356
- const childrenByName = (node, localName) => node
3357
- ? directChildren(node).filter((child) => child.localName === localName)
3358
- : [];
3359
- const docxAttr = (node, name) => {
3360
- if (!node)
3361
- return '';
3362
- return (node.getAttribute(`w:${name}`) ||
3363
- node.getAttribute(name) ||
3364
- node.getAttributeNS('http://schemas.openxmlformats.org/wordprocessingml/2006/main', name) ||
3365
- '');
3366
- };
3367
- const docxHexColor = (value) => {
3368
- if (!value || value.toLowerCase() === 'auto')
3369
- return '';
3370
- const normalized = value.replace(/[^0-9a-f]/gi, '');
3371
- return normalized.length === 6 ? `#${normalized}` : '';
3372
- };
3373
- const twipsToPt = (value) => {
3374
- const n = Number(value);
3375
- return Number.isFinite(n) ? `${Math.max(n / 20, 0)}pt` : '';
3376
- };
3377
- const halfPointsToPt = (value) => {
3378
- const n = Number(value);
3379
- return Number.isFinite(n) ? `${Math.max(n / 2, 1)}pt` : '';
3380
- };
3381
- const cssRules = (rules) => rules
3382
- .filter(([, value]) => Boolean(value))
3383
- .map(([name, value]) => `${name}: ${value}`)
3384
- .join('; ');
3385
- const styleAttr = (style) => (style ? ` style="${escapeHtml(style)}"` : '');
3386
- const convertDocxParagraphStyle = (paragraph) => {
3387
- const pPr = firstChildByName(paragraph, 'pPr');
3388
- if (!pPr)
3389
- return '';
3390
- const spacing = firstChildByName(pPr, 'spacing');
3391
- const jc = firstChildByName(pPr, 'jc');
3392
- const indent = firstChildByName(pPr, 'ind');
3393
- const borderBottom = firstChildByName(firstChildByName(pPr, 'pBdr'), 'bottom');
3394
- const line = docxAttr(spacing, 'line');
3395
- const lineRule = docxAttr(spacing, 'lineRule');
3396
- return cssRules([
3397
- ['text-align', docxAttr(jc, 'val')],
3398
- ['margin-top', twipsToPt(docxAttr(spacing, 'before'))],
3399
- ['margin-bottom', twipsToPt(docxAttr(spacing, 'after'))],
3400
- ['margin-left', twipsToPt(docxAttr(indent, 'left'))],
3401
- ['text-indent', twipsToPt(docxAttr(indent, 'firstLine'))],
3402
- ['line-height', line && lineRule === 'auto' ? `${Number(line) / 240}` : ''],
3403
- ['border-bottom', docxBorderCss(borderBottom)],
3404
- ]);
3405
- };
3406
- const convertDocxRunStyle = (run) => {
3407
- const rPr = firstChildByName(run, 'rPr');
3408
- if (!rPr)
3409
- return '';
3410
- const color = docxHexColor(docxAttr(firstChildByName(rPr, 'color'), 'val'));
3411
- const highlight = docxHexColor(docxAttr(firstChildByName(rPr, 'highlight'), 'val'));
3412
- const shade = docxHexColor(docxAttr(firstChildByName(rPr, 'shd'), 'fill'));
3413
- const size = halfPointsToPt(docxAttr(firstChildByName(rPr, 'sz'), 'val'));
3414
- const underline = firstChildByName(rPr, 'u');
3415
- return cssRules([
3416
- ['font-weight', firstChildByName(rPr, 'b') ? '700' : ''],
3417
- ['font-style', firstChildByName(rPr, 'i') ? 'italic' : ''],
3418
- ['text-decoration', underline ? 'underline' : ''],
3419
- ['color', color],
3420
- ['background-color', highlight || shade],
3421
- ['font-size', size],
3422
- ]);
3423
- };
3424
- const convertDocxRun = (run) => {
3425
- const rPr = firstChildByName(run, 'rPr');
3426
- const vertAlign = docxAttr(firstChildByName(rPr, 'vertAlign'), 'val');
3427
- const style = convertDocxRunStyle(run);
3428
- const content = directChildren(run)
3429
- .map((child) => {
3430
- if (child.localName === 't')
3431
- return escapeHtml(child.textContent || '');
3432
- if (child.localName === 'tab')
3433
- return '&emsp;';
3434
- if (child.localName === 'br') {
3435
- return docxAttr(child, 'type') === 'page'
3436
- ? '<hr class="srte-docx-page-break">'
3437
- : '<br>';
3438
- }
3439
- return '';
3440
- })
3441
- .join('');
3442
- if (!content)
3443
- return '';
3444
- const tag = vertAlign === 'superscript' ? 'sup' : vertAlign === 'subscript' ? 'sub' : 'span';
3445
- return `<${tag}${styleAttr(style)}>${content}</${tag}>`;
3446
- };
3447
- const convertDocxParagraph = (paragraph) => {
3448
- const style = convertDocxParagraphStyle(paragraph);
3449
- const content = childrenByName(paragraph, 'r').map(convertDocxRun).join('');
3450
- return `<p${styleAttr(style)}>${content || '<br>'}</p>`;
3451
- };
3452
- const docxBorderCss = (border) => {
3453
- if (!border)
3454
- return '';
3455
- const val = docxAttr(border, 'val');
3456
- if (!val || val === 'nil' || val === 'none')
3457
- return '';
3458
- const size = Number(docxAttr(border, 'sz')) || 4;
3459
- const width = Math.max(size / 8, 0.5);
3460
- const color = docxHexColor(docxAttr(border, 'color')) || '#d1d5db';
3461
- return `${width}px solid ${color}`;
3462
- };
3463
- const convertDocxCellStyle = (cell) => {
3464
- const tcPr = firstChildByName(cell, 'tcPr');
3465
- const width = twipsToPt(docxAttr(firstChildByName(tcPr, 'tcW'), 'w'));
3466
- const shade = docxHexColor(docxAttr(firstChildByName(tcPr, 'shd'), 'fill'));
3467
- const borders = firstChildByName(tcPr, 'tcBorders');
3468
- const top = docxBorderCss(firstChildByName(borders, 'top'));
3469
- const right = docxBorderCss(firstChildByName(borders, 'right'));
3470
- const bottom = docxBorderCss(firstChildByName(borders, 'bottom'));
3471
- const left = docxBorderCss(firstChildByName(borders, 'left'));
3472
- return cssRules([
3473
- ['width', width],
3474
- ['background-color', shade],
3475
- ['border-top', top],
3476
- ['border-right', right],
3477
- ['border-bottom', bottom],
3478
- ['border-left', left],
3479
- ['padding', '8px'],
3480
- ['vertical-align', 'top'],
3481
- ]);
3482
- };
3483
- const convertDocxTable = (table) => {
3484
- const rows = childrenByName(table, 'tr')
3485
- .map((row) => {
3486
- const cells = childrenByName(row, 'tc')
3487
- .map((cell) => {
3488
- const content = directChildren(cell)
3489
- .filter((child) => child.localName === 'p' || child.localName === 'tbl')
3490
- .map((child) => child.localName === 'p' ? convertDocxParagraph(child) : convertDocxTable(child))
3491
- .join('');
3492
- return `<td${styleAttr(convertDocxCellStyle(cell))}>${content || '<p><br></p>'}</td>`;
3493
- })
3494
- .join('');
3495
- return `<tr>${cells}</tr>`;
3496
- })
3497
- .join('');
3498
- return `<table style="border-collapse: collapse; width: 100%; margin: 12px 0;"><tbody>${rows}</tbody></table>`;
3499
- };
3500
- const enhanceImportedTables = (root) => {
3501
- const tables = root.querySelectorAll('table');
3502
- tables.forEach(tbl => {
3503
- tbl.style.borderCollapse = tbl.style.borderCollapse || 'collapse';
3504
- tbl.style.width = tbl.style.width || '100%';
3505
- const cells = tbl.querySelectorAll('td, th');
3506
- cells.forEach(cell => {
3507
- const el = cell;
3508
- if (!el.style.border && !el.style.borderTop && !el.style.borderRight && !el.style.borderBottom && !el.style.borderLeft) {
3509
- el.style.border = '1px solid #d1d5db';
3510
- }
3511
- el.style.padding = el.style.padding || '8px';
3512
- el.style.verticalAlign = el.style.verticalAlign || 'top';
3513
- });
3514
- });
3515
- };
3516
- const escapeHtml = (value) => value
3517
- .replace(/&/g, "&amp;")
3518
- .replace(/</g, "&lt;")
3519
- .replace(/>/g, "&gt;");
3520
- const htmlToMarkdown = (html) => {
3521
- const root = document.createElement("div");
3522
- root.innerHTML = html;
3523
- const walk = (node) => {
3524
- if (node.nodeType === Node.TEXT_NODE)
3525
- return node.textContent || "";
3526
- if (!(node instanceof HTMLElement))
3527
- return "";
3528
- const content = Array.from(node.childNodes).map(walk).join("");
3529
- const tag = node.tagName.toLowerCase();
3530
- if (tag === "strong" || tag === "b")
3531
- return `**${content}**`;
3532
- if (tag === "em" || tag === "i")
3533
- return `*${content}*`;
3534
- if (tag === "code")
3535
- return `\`${content}\``;
3536
- if (tag === "br")
3537
- return "\n";
3538
- if (/h[1-6]/.test(tag))
3539
- return `${"#".repeat(Number(tag[1]))} ${content.trim()}\n\n`;
3540
- if (tag === "p")
3541
- return `${content.trim()}\n\n`;
3542
- if (tag === "li")
3543
- return `- ${content.trim()}\n`;
3544
- if (tag === "ul" || tag === "ol")
3545
- return `${content}\n`;
3546
- if (tag === "blockquote")
3547
- return `> ${content.trim()}\n\n`;
3548
- if (tag === "table")
3549
- return `${node.outerHTML}\n\n`;
3550
- if (tag === "img")
3551
- return `![${node.getAttribute("alt") || ""}](${node.getAttribute("src") || ""})`;
3552
- if (tag === "a")
3553
- return `[${content}](${node.getAttribute("href") || ""})`;
3554
- return content;
3555
- };
3556
- return Array.from(root.childNodes).map(walk).join("").replace(/\n{3,}/g, "\n\n").trim();
3557
- };
3558
- const importTextFile = async (files, type) => {
3559
- if (!files || files.length === 0)
3560
- return;
3561
- const file = files[0];
3562
- const text = await file.text();
3563
- const html = type === "html" ? text : markdownToCompatibilityHtml(text);
3564
- const el = editableRef.current;
3565
- const hasContent = el && el.textContent && el.textContent.trim().length > 0;
3566
- insertImportedHtml(html, hasContent ? "append" : "replace", {
3567
- preserveColors: true,
3568
- preserveDocumentLayout: true,
3569
- });
3570
- };
3571
- const downloadText = (filename, content, mimeType) => {
3572
- const blob = new Blob([content], { type: mimeType });
3573
- const url = URL.createObjectURL(blob);
3574
- const link = document.createElement("a");
3575
- link.href = url;
3576
- link.download = filename;
3577
- document.body.appendChild(link);
3578
- link.click();
3579
- link.remove();
3580
- URL.revokeObjectURL(url);
3581
- };
3582
- const buildStandaloneHtmlDocument = (html) => `<!doctype html>
3583
- <html lang="en">
3584
- <head>
3585
- <meta charset="utf-8">
3586
- <meta name="viewport" content="width=device-width, initial-scale=1">
3587
- <title>Smart RTE Export</title>
3588
- <style>
3589
- :root {
3590
- --srte-bg: #ffffff;
3591
- --srte-text: #111111;
3592
- --srte-text-muted: #4b5563;
3593
- --srte-border: #d1d5db;
3594
- --srte-border-light: #e5e7eb;
3595
- --srte-accent: #1e90ff;
3596
- --srte-accent-bg: rgba(30, 144, 255, 0.15);
3597
- --srte-surface-subtle: #f3f4f6;
3598
- }
3599
- html, body {
3600
- margin: 0;
3601
- background: var(--srte-bg);
3602
- color: var(--srte-text);
3603
- font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
3604
- line-height: 1.6;
3605
- }
3606
- body {
3607
- padding: 32px;
3608
- }
3609
- .srte-export {
3610
- max-width: 960px;
3611
- margin: 0 auto;
3612
- }
3613
- .srte-export h1,
3614
- .srte-export h2,
3615
- .srte-export h3,
3616
- .srte-export h4,
3617
- .srte-export h5,
3618
- .srte-export h6 {
3619
- line-height: 1.3;
3620
- margin: 1.25em 0 0.5em;
3621
- font-weight: 700;
3622
- color: var(--srte-text);
3623
- }
3624
- .srte-export p {
3625
- margin: 0 0 0.85em;
3626
- }
3627
- .srte-export blockquote {
3628
- border-left: 4px solid var(--srte-accent);
3629
- margin: 0.75em 0;
3630
- padding: 0.5em 1em;
3631
- background: var(--srte-surface-subtle);
3632
- color: var(--srte-text);
3633
- }
3634
- .srte-export ul,
3635
- .srte-export ol {
3636
- margin: 0.75em 0;
3637
- padding-left: 1.75em;
3638
- list-style-position: outside;
3639
- }
3640
- .srte-export ul {
3641
- list-style-type: disc;
3642
- }
3643
- .srte-export ol {
3644
- list-style-type: decimal;
3645
- }
3646
- .srte-export li {
3647
- display: list-item;
3648
- margin: 0.25em 0;
3649
- padding-left: 0.25em;
3650
- }
3651
- .srte-export table {
3652
- border-collapse: collapse;
3653
- width: 100%;
3654
- margin: 12px 0;
3655
- }
3656
- .srte-export td,
3657
- .srte-export th {
3658
- border: 1px solid var(--srte-border);
3659
- padding: 8px;
3660
- vertical-align: top;
3661
- text-align: left;
3662
- }
3663
- .srte-export th {
3664
- background: var(--srte-surface-subtle);
3665
- font-weight: 700;
3666
- }
3667
- .srte-export img {
3668
- max-width: 100%;
3669
- height: auto;
3670
- }
3671
- .srte-export pre,
3672
- .srte-export code {
3673
- background: var(--srte-surface-subtle);
3674
- color: var(--srte-text);
3675
- border-radius: 4px;
3676
- }
3677
- .srte-export pre {
3678
- padding: 12px;
3679
- overflow-x: auto;
3680
- white-space: pre-wrap;
3681
- }
3682
- @media print {
3683
- body {
3684
- padding: 0;
3685
- }
3686
- .srte-export {
3687
- max-width: none;
3688
- }
3689
- }
3690
- </style>
3691
- </head>
3692
- <body>
3693
- <main class="srte-export">${html || "<p></p>"}</main>
3694
- </body>
3695
- </html>`;
3696
- const exportHtml = () => {
3697
- const html = editableRef.current?.innerHTML || "";
3698
- downloadText("smart-rte-export.html", buildStandaloneHtmlDocument(html), "text/html;charset=utf-8");
3699
- };
3700
- const exportMarkdown = () => {
3701
- const html = editableRef.current?.innerHTML || "";
3702
- downloadText("smart-rte-export.md", htmlToMarkdown(html), "text/markdown");
3703
- };
3704
- const htmlToDocxXml = (html) => {
3705
- const root = document.createElement("div");
3706
- root.innerHTML = html;
3707
- const xmlEscape = (value) => value
3708
- .replace(/&/g, "&amp;")
3709
- .replace(/</g, "&lt;")
3710
- .replace(/>/g, "&gt;")
3711
- .replace(/"/g, "&quot;");
3712
- const colorValue = (value) => {
3713
- const hex = /^#([0-9a-f]{6})$/i.exec(value.trim());
3714
- if (hex)
3715
- return hex[1].toUpperCase();
3716
- const rgb = /^rgb\(\s*(\d+),\s*(\d+),\s*(\d+)\s*\)$/i.exec(value.trim());
3717
- if (!rgb)
3718
- return "";
3719
- return [rgb[1], rgb[2], rgb[3]]
3720
- .map((part) => Math.max(0, Math.min(255, Number(part))).toString(16).padStart(2, "0"))
3721
- .join("")
3722
- .toUpperCase();
3723
- };
3724
- const sizeToHalfPoints = (value) => {
3725
- const trimmed = value.trim();
3726
- const match = /^([\d.]+)(px|pt)$/i.exec(trimmed);
3727
- if (!match)
3728
- return "";
3729
- const raw = Number(match[1]);
3730
- const pt = match[2].toLowerCase() === "px" ? raw * 0.75 : raw;
3731
- return String(Math.max(2, Math.round(pt * 2)));
3732
- };
3733
- const runProperties = (el) => {
3734
- const style = el.style;
3735
- const color = colorValue(style.color);
3736
- const size = sizeToHalfPoints(style.fontSize);
3737
- const isBold = el.tagName === "B" || el.tagName === "STRONG" || /bold|700|800|900/.test(style.fontWeight);
3738
- const isItalic = el.tagName === "I" || el.tagName === "EM" || style.fontStyle === "italic";
3739
- const isUnderline = el.tagName === "U" || style.textDecoration.includes("underline");
3740
- return [
3741
- isBold ? "<w:b/>" : "",
3742
- isItalic ? "<w:i/>" : "",
3743
- isUnderline ? '<w:u w:val="single"/>' : "",
3744
- color ? `<w:color w:val="${color}"/>` : "",
3745
- size ? `<w:sz w:val="${size}"/>` : "",
3746
- ].join("");
3747
- };
3748
- const runs = (node, inheritedProps = "") => {
3749
- if (node.nodeType === Node.TEXT_NODE) {
3750
- const text = node.textContent || "";
3751
- return text ? `<w:r>${inheritedProps ? `<w:rPr>${inheritedProps}</w:rPr>` : ""}<w:t xml:space="preserve">${xmlEscape(text)}</w:t></w:r>` : "";
3752
- }
3753
- if (!(node instanceof HTMLElement))
3754
- return "";
3755
- if (node.tagName === "BR")
3756
- return "<w:r><w:br/></w:r>";
3757
- if (node.tagName === "IMG") {
3758
- const alt = node.getAttribute("alt") || node.getAttribute("title") || "Image";
3759
- return `<w:r><w:t>[Image: ${xmlEscape(alt)}]</w:t></w:r>`;
3760
- }
3761
- const props = `${inheritedProps}${runProperties(node)}`;
3762
- return Array.from(node.childNodes).map((child) => runs(child, props)).join("");
3763
- };
3764
- const paragraph = (el, fallbackTag = "p") => {
3765
- const tag = el.tagName.toLowerCase();
3766
- const headingMatch = /^h([1-6])$/.exec(tag);
3767
- const style = el.style;
3768
- const align = style.textAlign ? `<w:jc w:val="${xmlEscape(style.textAlign)}"/>` : "";
3769
- const headingSize = headingMatch ? `<w:rPr><w:b/><w:sz w:val="${Math.max(24, 40 - Number(headingMatch[1]) * 4)}"/></w:rPr>` : "";
3770
- const body = runs(el);
3771
- return `<w:p><w:pPr>${align}${headingSize}</w:pPr>${body || "<w:r><w:t></w:t></w:r>"}</w:p>`;
3772
- };
3773
- const tableCell = (cell) => {
3774
- const fill = colorValue(cell.style.backgroundColor);
3775
- const shading = fill ? `<w:shd w:val="clear" w:color="auto" w:fill="${fill}"/>` : "";
3776
- const cellContent = Array.from(cell.childNodes)
3777
- .map((child) => child instanceof HTMLElement && ["P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6"].includes(child.tagName)
3778
- ? paragraph(child)
3779
- : `<w:p>${runs(child)}</w:p>`)
3780
- .join("");
3781
- return `<w:tc><w:tcPr>${shading}<w:tcBorders><w:top w:val="single" w:sz="4" w:color="D1D5DB"/><w:left w:val="single" w:sz="4" w:color="D1D5DB"/><w:bottom w:val="single" w:sz="4" w:color="D1D5DB"/><w:right w:val="single" w:sz="4" w:color="D1D5DB"/></w:tcBorders></w:tcPr>${cellContent || "<w:p/>"}</w:tc>`;
3782
- };
3783
- const tableXml = (table) => {
3784
- const rows = Array.from(table.querySelectorAll("tr"));
3785
- return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:color="D1D5DB"/><w:left w:val="single" w:sz="4" w:color="D1D5DB"/><w:bottom w:val="single" w:sz="4" w:color="D1D5DB"/><w:right w:val="single" w:sz="4" w:color="D1D5DB"/><w:insideH w:val="single" w:sz="4" w:color="D1D5DB"/><w:insideV w:val="single" w:sz="4" w:color="D1D5DB"/></w:tblBorders></w:tblPr>${rows.map((row) => `<w:tr>${Array.from(row.children).map((cell) => tableCell(cell)).join("")}</w:tr>`).join("")}</w:tbl>`;
3786
- };
3787
- const blockXml = (node) => {
3788
- if (node.nodeType === Node.TEXT_NODE) {
3789
- const text = node.textContent?.trim();
3790
- return text ? `<w:p>${runs(node)}</w:p>` : "";
3791
- }
3792
- if (!(node instanceof HTMLElement))
3793
- return "";
3794
- if (node.tagName === "TABLE")
3795
- return tableXml(node);
3796
- if (node.tagName === "UL" || node.tagName === "OL") {
3797
- return Array.from(node.children).map((li) => `<w:p><w:r><w:t>• </w:t></w:r>${runs(li)}</w:p>`).join("");
3798
- }
3799
- if (node.tagName === "BLOCKQUOTE") {
3800
- return `<w:p><w:pPr><w:ind w:left="720"/></w:pPr>${runs(node)}</w:p>`;
3801
- }
3802
- if (node.tagName === "HR")
3803
- return '<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:color="D1D5DB"/></w:pBdr></w:pPr></w:p>';
3804
- if (["P", "DIV", "PRE", "H1", "H2", "H3", "H4", "H5", "H6"].includes(node.tagName))
3805
- return paragraph(node);
3806
- return Array.from(node.childNodes).map(blockXml).join("");
3807
- };
3808
- const body = Array.from(root.childNodes).map(blockXml).join("");
3809
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="720" w:right="720" w:bottom="720" w:left="720"/></w:sectPr></w:body></w:document>`;
3810
- };
3811
- const exportDocx = async () => {
3812
- const html = editableRef.current?.innerHTML || "";
3813
- const zip = new JSZip();
3814
- zip.file("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`);
3815
- zip.folder("_rels")?.file(".rels", `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`);
3816
- zip.folder("word")?.file("document.xml", htmlToDocxXml(html));
3817
- zip.folder("word")?.folder("_rels")?.file("document.xml.rels", `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`);
3818
- const blob = await zip.generateAsync({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" });
3819
- const url = URL.createObjectURL(blob);
3820
- const link = document.createElement("a");
3821
- link.href = url;
3822
- link.download = "smart-rte-export.docx";
3823
- document.body.appendChild(link);
3824
- link.click();
3825
- link.remove();
3826
- URL.revokeObjectURL(url);
3827
- };
3828
- const exportPdf = () => {
3829
- const html = editableRef.current?.innerHTML || "";
3830
- const printWindow = window.open("", "_blank", "width=900,height=700");
3831
- if (!printWindow)
3832
- return;
3833
- printWindow.document.open();
3834
- printWindow.document.write(`<!doctype html><html><head><title>Export PDF</title><style>@page{margin:18mm}html,body{background:#fff}body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.6;padding:32px;color:#111}table{border-collapse:collapse;width:100%;margin:12px 0;break-inside:auto}tr,img,blockquote,pre{break-inside:avoid}td,th{border:1px solid #d1d5db;padding:8px;vertical-align:top}img{max-width:100%;height:auto}blockquote{border-left:4px solid #d1d5db;padding-left:12px;color:#374151}pre,code{background:#f3f4f6}pre{padding:12px;white-space:pre-wrap}@media print{body{padding:0}}</style></head><body>${html || "<p></p>"}<script>window.addEventListener("load",function(){setTimeout(function(){window.focus();window.print();},150);});</script></body></html>`);
3835
- printWindow.document.close();
3836
- };
3837
- const fixNegativeMargins = (root) => {
3559
+ const fixNegativeMargins = (root) => {
3838
3560
  try {
3839
3561
  const nodes = root.querySelectorAll('*');
3840
3562
  for (let i = 0; i < nodes.length; i++) {
@@ -3908,6 +3630,12 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3908
3630
  const style = node.getAttribute('style');
3909
3631
  if (!style)
3910
3632
  return;
3633
+ // blockquote/pre carry their own consistent border+padding from the
3634
+ // editor stylesheet; an inline border/padding pasted in from the
3635
+ // source page (e.g. a "border: 0" reset) silently wins the cascade
3636
+ // over that stylesheet rule and leaves them looking broken.
3637
+ const tagName = node.tagName.toLowerCase();
3638
+ const dropsBorderAndPadding = tagName === 'blockquote' || tagName === 'pre';
3911
3639
  const safeRules = style
3912
3640
  .split(';')
3913
3641
  .map((rule) => rule.trim())
@@ -3920,6 +3648,8 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3920
3648
  const value = rule.slice(separator + 1).trim().toLowerCase();
3921
3649
  if (!allowedStyleNames.has(name))
3922
3650
  return false;
3651
+ if (dropsBorderAndPadding && (name.startsWith('border') || name.startsWith('padding')))
3652
+ return false;
3923
3653
  if (value.includes('position') || value.includes('expression') || value.includes('javascript:'))
3924
3654
  return false;
3925
3655
  if (name === 'white-space' && value !== 'pre-wrap')
@@ -3940,9 +3670,55 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3940
3670
  if (!el)
3941
3671
  return;
3942
3672
  fixNegativeMargins(el);
3673
+ normalizeListNesting(el);
3943
3674
  ensureTableWrappers(el);
3944
3675
  addTableResizeHandles();
3945
3676
  };
3677
+ /** Repairs list markup emitted by Google Docs and browsers where a nested
3678
+ * list is serialized as a sibling of its parent LI instead of a child. */
3679
+ const normalizeListNesting = (root) => {
3680
+ root.querySelectorAll("ul,ol").forEach((list) => {
3681
+ let currentItem = null;
3682
+ Array.from(list.children).forEach((child) => {
3683
+ if (!(child instanceof HTMLElement))
3684
+ return;
3685
+ if (child.tagName === "LI") {
3686
+ currentItem = child;
3687
+ return;
3688
+ }
3689
+ if (currentItem && child.parentElement === list && (child.tagName === "UL" || child.tagName === "OL")) {
3690
+ currentItem.appendChild(child);
3691
+ }
3692
+ });
3693
+ const firstItem = list.querySelector(":scope > li");
3694
+ if (!list.style.listStyleType && firstItem?.style.listStyleType) {
3695
+ list.style.listStyleType = firstItem.style.listStyleType;
3696
+ }
3697
+ // Imported document HTML often places list-style-type on every LI.
3698
+ // That overrides later changes made on the UL/OL and makes the toolbar
3699
+ // appear inert. The canonical DOM stores marker style on the list only.
3700
+ list.querySelectorAll(":scope > li").forEach((item) => {
3701
+ item.style.removeProperty("list-style-type");
3702
+ });
3703
+ list.querySelectorAll(":scope > li > ul, :scope > li > ol").forEach((nested) => {
3704
+ const nestedItem = nested.querySelector(":scope > li");
3705
+ if (!nested.style.listStyleType && nestedItem?.style.listStyleType) {
3706
+ nested.style.listStyleType = nestedItem.style.listStyleType;
3707
+ }
3708
+ });
3709
+ });
3710
+ root.querySelectorAll("ul[data-srte-list-preset],ol[data-srte-list-preset]").forEach((list) => {
3711
+ const preset = list.dataset.srteListPreset;
3712
+ if (!isSmartListPreset(preset)) {
3713
+ clearListPreset(list);
3714
+ return;
3715
+ }
3716
+ const ancestorWithPreset = list.parentElement?.closest("ul[data-srte-list-preset],ol[data-srte-list-preset]");
3717
+ if (ancestorWithPreset?.dataset.srteListPreset === preset)
3718
+ return;
3719
+ applyListPresetHierarchy(list, preset);
3720
+ });
3721
+ };
3946
3722
  const insertHtmlAtEnd = (html) => {
3947
3723
  const el = editableRef.current;
3948
3724
  if (!el)
@@ -4053,6 +3829,27 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4053
3829
  }
4054
3830
  });
4055
3831
  };
3832
+ // blockquote/pre always get their border+padding from the editor
3833
+ // stylesheet, which an inline style (e.g. carried in from a paste, or set
3834
+ // directly via the value prop) silently overrides regardless of how it
3835
+ // got there. Strip those two properties so the editor's own look always
3836
+ // wins, no matter the element's origin.
3837
+ const normalizeBlockContainerStyling = (root) => {
3838
+ root.querySelectorAll("blockquote,pre").forEach((element) => {
3839
+ element.style.removeProperty("border");
3840
+ element.style.removeProperty("border-top");
3841
+ element.style.removeProperty("border-right");
3842
+ element.style.removeProperty("border-bottom");
3843
+ element.style.removeProperty("border-left");
3844
+ element.style.removeProperty("padding");
3845
+ element.style.removeProperty("padding-top");
3846
+ element.style.removeProperty("padding-right");
3847
+ element.style.removeProperty("padding-bottom");
3848
+ element.style.removeProperty("padding-left");
3849
+ if (!element.getAttribute("style"))
3850
+ element.removeAttribute("style");
3851
+ });
3852
+ };
4056
3853
  const normalizeInvalidQuoteNesting = (root) => {
4057
3854
  root.querySelectorAll("blockquote blockquote").forEach((quote) => {
4058
3855
  const outerQuote = quote.parentElement?.closest("blockquote");
@@ -4080,7 +3877,10 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4080
3877
  outerCodeBlock.parentElement.insertBefore(codeBlock, outerCodeBlock.nextSibling);
4081
3878
  }
4082
3879
  });
4083
- root.querySelectorAll("p,h1,h2,h3,h4,h5,h6,blockquote").forEach((container) => {
3880
+ // p/h1-h6 can only hold inline content, so a pre landing inside one (e.g.
3881
+ // from bad paste HTML) is genuinely invalid and gets hoisted out. A
3882
+ // blockquote is a real block container and is allowed to hold a pre.
3883
+ root.querySelectorAll("p,h1,h2,h3,h4,h5,h6").forEach((container) => {
4084
3884
  const nestedCodeBlocks = Array.from(container.children).filter((child) => child.tagName === "PRE");
4085
3885
  nestedCodeBlocks.forEach((codeBlock) => {
4086
3886
  container.parentElement?.insertBefore(codeBlock, container.nextSibling);
@@ -4125,10 +3925,12 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4125
3925
  return;
4126
3926
  // Auto-fix negative margins that might cause visibility issues
4127
3927
  fixNegativeMargins(el);
3928
+ normalizeListNesting(el);
4128
3929
  // Quotes are document blocks and cannot be nested by drag and drop.
4129
3930
  normalizeInvalidQuoteNesting(el);
4130
3931
  // Code blocks are document blocks and cannot be nested by drag and drop.
4131
3932
  normalizeInvalidCodeBlockNesting(el);
3933
+ normalizeBlockContainerStyling(el);
4132
3934
  // Tables are document-level blocks and must not remain inside code or list items.
4133
3935
  normalizeInvalidTableNesting(el);
4134
3936
  // Ensure tables are wrapped for horizontal scrolling
@@ -4181,6 +3983,27 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4181
3983
  sel?.removeAllRanges();
4182
3984
  sel?.addRange(range);
4183
3985
  }
3986
+ if (range?.collapsed) {
3987
+ pushEditorHistory();
3988
+ const inserted = editorControllerRef.current.bindRoot(el).insertTable(tableRows, tableCols);
3989
+ if (inserted) {
3990
+ Array.from(inserted.rows).forEach((row, rowIndex) => {
3991
+ row.setAttribute("data-row-index", String(rowIndex));
3992
+ cellsOfRow(row).forEach((cell, cellIndex) => {
3993
+ cell.setAttribute("data-col-index", String(cellIndex));
3994
+ cell.style.border = cell.style.border || "1px solid #d1d5db";
3995
+ cell.style.padding = cell.style.padding || "6px";
3996
+ cell.style.minWidth = cell.style.minWidth || "60px";
3997
+ });
3998
+ });
3999
+ addTableResizeHandles();
4000
+ const firstCell = inserted.querySelector("td,th");
4001
+ if (firstCell instanceof HTMLTableCellElement)
4002
+ moveCaretToCell(firstCell, false);
4003
+ handleInput();
4004
+ return;
4005
+ }
4006
+ }
4184
4007
  const html = buildTableHTML(tableRows, tableCols);
4185
4008
  // Insert via Range for broader support
4186
4009
  const wrapper = document.createElement("div");
@@ -4351,6 +4174,26 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4351
4174
  if (!anchor)
4352
4175
  return;
4353
4176
  pushEditorHistory();
4177
+ clearSelectionDecor();
4178
+ const table = tbody.closest("table");
4179
+ if (table instanceof HTMLTableElement) {
4180
+ const replacement = editorControllerRef.current.bindRoot(editableRef.current).executeTableCommand(table, {
4181
+ id: "table.cell.merge",
4182
+ input: {
4183
+ start: { row: sr, column: sc },
4184
+ end: { row: er, column: ec },
4185
+ },
4186
+ });
4187
+ if (replacement) {
4188
+ const nextBody = replacement.tBodies[0];
4189
+ const nextAnchor = nextBody ? getTableGrid(nextBody).grid[sr]?.[sc] : null;
4190
+ if (nextAnchor)
4191
+ moveCaretToCell(nextAnchor, false);
4192
+ addTableResizeHandles();
4193
+ handleInput();
4194
+ return;
4195
+ }
4196
+ }
4354
4197
  // Collect content and remove other cells
4355
4198
  const contents = [];
4356
4199
  const cellsToMerge = getCellsInGridRect(tbody, sr, sc, er, ec);
@@ -4380,7 +4223,20 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4380
4223
  const pos = getCellPosition(cell);
4381
4224
  if (!pos)
4382
4225
  return;
4383
- const { row, tbody, rIdx } = pos;
4226
+ const { row, tbody, rIdx, table } = pos;
4227
+ const insertIndex = dir === "above" ? rIdx : rIdx + 1;
4228
+ const replacement = editorControllerRef.current.bindRoot(editableRef.current).executeTableCommand(table, {
4229
+ id: "table.row.add",
4230
+ input: { index: insertIndex },
4231
+ });
4232
+ if (replacement) {
4233
+ const nextBody = replacement.tBodies[0];
4234
+ const nextCell = nextBody ? getTableGrid(nextBody).grid[insertIndex]?.[0] : null;
4235
+ if (nextCell)
4236
+ moveCaretToCell(nextCell, false);
4237
+ addTableResizeHandles();
4238
+ return;
4239
+ }
4384
4240
  const newRow = document.createElement("tr");
4385
4241
  const numCols = Array.from(row.children).filter((c) => ["TD", "TH"].includes(c.tagName)).length;
4386
4242
  for (let i = 0; i < numCols; i++) {
@@ -4391,7 +4247,6 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4391
4247
  td.innerHTML = "&nbsp;";
4392
4248
  newRow.appendChild(td);
4393
4249
  }
4394
- const insertIndex = dir === "above" ? rIdx : rIdx + 1;
4395
4250
  const refRow = tbody.children[insertIndex] || null;
4396
4251
  tbody.insertBefore(newRow, refRow);
4397
4252
  };
@@ -4399,7 +4254,20 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4399
4254
  const pos = getCellPosition(cell);
4400
4255
  if (!pos)
4401
4256
  return;
4402
- const { row, tbody, table } = pos;
4257
+ const { row, tbody, table, rIdx } = pos;
4258
+ const replacement = editorControllerRef.current.bindRoot(editableRef.current).executeTableCommand(table, {
4259
+ id: "table.row.remove",
4260
+ input: { index: rIdx },
4261
+ });
4262
+ if (replacement) {
4263
+ const nextBody = replacement.tBodies[0];
4264
+ const nextRow = Math.min(rIdx, Math.max(0, replacement.rows.length - 1));
4265
+ const nextCell = nextBody ? getTableGrid(nextBody).grid[nextRow]?.[0] : null;
4266
+ if (nextCell)
4267
+ moveCaretToCell(nextCell, false);
4268
+ addTableResizeHandles();
4269
+ return;
4270
+ }
4403
4271
  tbody.removeChild(row);
4404
4272
  if (tbody.querySelectorAll("tr").length === 0) {
4405
4273
  table.parentElement?.removeChild(table);
@@ -4410,8 +4278,20 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4410
4278
  if (!pos)
4411
4279
  return;
4412
4280
  const { tbody, cIdx } = pos;
4413
- const rows = Array.from(tbody.querySelectorAll("tr"));
4414
4281
  const insertIndex = dir === "left" ? cIdx : cIdx + 1;
4282
+ const replacement = editorControllerRef.current.bindRoot(editableRef.current).executeTableCommand(pos.table, {
4283
+ id: "table.column.add",
4284
+ input: { index: insertIndex },
4285
+ });
4286
+ if (replacement) {
4287
+ const nextBody = replacement.tBodies[0];
4288
+ const nextCell = nextBody ? getTableGrid(nextBody).grid[0]?.[insertIndex] : null;
4289
+ if (nextCell)
4290
+ moveCaretToCell(nextCell, false);
4291
+ addTableResizeHandles();
4292
+ return;
4293
+ }
4294
+ const rows = Array.from(tbody.querySelectorAll("tr"));
4415
4295
  for (const r of rows) {
4416
4296
  const cells = Array.from(r.children).filter((c) => ["TD", "TH"].includes(c.tagName));
4417
4297
  const td = document.createElement("td");
@@ -4431,6 +4311,20 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4431
4311
  if (!pos)
4432
4312
  return;
4433
4313
  const { tbody, table, cIdx } = pos;
4314
+ const replacement = editorControllerRef.current.bindRoot(editableRef.current).executeTableCommand(table, {
4315
+ id: "table.column.remove",
4316
+ input: { index: cIdx },
4317
+ });
4318
+ if (replacement) {
4319
+ const nextBody = replacement.tBodies[0];
4320
+ const nextGrid = nextBody ? getTableGrid(nextBody).grid : [];
4321
+ const nextColumn = Math.min(cIdx, Math.max(0, (nextGrid[0]?.length || 1) - 1));
4322
+ const nextCell = nextGrid[0]?.[nextColumn] || null;
4323
+ if (nextCell)
4324
+ moveCaretToCell(nextCell, false);
4325
+ addTableResizeHandles();
4326
+ return;
4327
+ }
4434
4328
  const rows = Array.from(tbody.querySelectorAll("tr"));
4435
4329
  for (const r of rows) {
4436
4330
  const cells = Array.from(r.children).filter((c) => ["TD", "TH"].includes(c.tagName));
@@ -4444,7 +4338,21 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4444
4338
  table.parentElement?.removeChild(table);
4445
4339
  };
4446
4340
  const toggleHeaderCell = (cell) => {
4341
+ const pos = getCellPosition(cell);
4342
+ if (!pos)
4343
+ return;
4447
4344
  clearSelectionDecor();
4345
+ const replacement = editorControllerRef.current.bindRoot(editableRef.current).executeTableCommand(pos.table, {
4346
+ id: "table.header.cell.toggle",
4347
+ input: { row: pos.rIdx, column: pos.cIdx },
4348
+ });
4349
+ if (replacement) {
4350
+ const nextCell = getTableGrid(replacement.tBodies[0]).grid[pos.rIdx]?.[pos.cIdx];
4351
+ if (nextCell)
4352
+ moveCaretToCell(nextCell, false);
4353
+ addTableResizeHandles();
4354
+ return;
4355
+ }
4448
4356
  const isTh = cell.tagName === "TH";
4449
4357
  replaceTableCellTag(cell, isTh ? "td" : "th");
4450
4358
  handleInput();
@@ -4454,6 +4362,8 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4454
4362
  if (!pos)
4455
4363
  return;
4456
4364
  const { table } = pos;
4365
+ if (editorControllerRef.current.bindRoot(editableRef.current).removeTable(table))
4366
+ return;
4457
4367
  table.parentElement?.removeChild(table);
4458
4368
  };
4459
4369
  const splitCell = (cell) => {
@@ -4465,6 +4375,19 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4465
4375
  const cs = Math.max(1, cell.colSpan || 1);
4466
4376
  if (rs === 1 && cs === 1)
4467
4377
  return;
4378
+ clearSelectionDecor();
4379
+ const replacement = editorControllerRef.current.bindRoot(editableRef.current).executeTableCommand(pos.table, {
4380
+ id: "table.cell.split",
4381
+ input: { row: rIdx, column: cIdx },
4382
+ });
4383
+ if (replacement) {
4384
+ const nextBody = replacement.tBodies[0];
4385
+ const nextCell = nextBody ? getTableGrid(nextBody).grid[rIdx]?.[cIdx] : null;
4386
+ if (nextCell)
4387
+ moveCaretToCell(nextCell, false);
4388
+ addTableResizeHandles();
4389
+ return;
4390
+ }
4468
4391
  // Reset current cell
4469
4392
  cell.rowSpan = 1;
4470
4393
  cell.colSpan = 1;
@@ -4501,6 +4424,17 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4501
4424
  if (!pos)
4502
4425
  return;
4503
4426
  clearSelectionDecor();
4427
+ const replacement = editorControllerRef.current.bindRoot(editableRef.current).executeTableCommand(pos.table, {
4428
+ id: "table.header.row.toggle",
4429
+ input: { row: pos.rIdx, column: pos.cIdx },
4430
+ });
4431
+ if (replacement) {
4432
+ const nextCell = getTableGrid(replacement.tBodies[0]).grid[pos.rIdx]?.[pos.cIdx];
4433
+ if (nextCell)
4434
+ moveCaretToCell(nextCell, false);
4435
+ addTableResizeHandles();
4436
+ return;
4437
+ }
4504
4438
  const { row } = pos;
4505
4439
  const cells = cellsOfRow(row);
4506
4440
  const shouldMakeHeader = cells.some((c) => c.tagName !== "TH");
@@ -4520,6 +4454,17 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4520
4454
  if (!pos)
4521
4455
  return;
4522
4456
  clearSelectionDecor();
4457
+ const replacement = editorControllerRef.current.bindRoot(editableRef.current).executeTableCommand(pos.table, {
4458
+ id: "table.header.column.toggle",
4459
+ input: { row: pos.rIdx, column: pos.cIdx },
4460
+ });
4461
+ if (replacement) {
4462
+ const nextCell = getTableGrid(replacement.tBodies[0]).grid[pos.rIdx]?.[pos.cIdx];
4463
+ if (nextCell)
4464
+ moveCaretToCell(nextCell, false);
4465
+ addTableResizeHandles();
4466
+ return;
4467
+ }
4523
4468
  const { tbody, cIdx } = pos;
4524
4469
  const { grid } = getTableGrid(tbody);
4525
4470
  const seen = new Set();
@@ -4539,25 +4484,44 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4539
4484
  };
4540
4485
  const applyBgToSelection = (hex, fallbackCell, explicitCells) => {
4541
4486
  const readableColor = readableTextColorForBackground(hex);
4487
+ const targetCells = explicitCells?.length
4488
+ ? explicitCells
4489
+ : shouldUseTableSelection(fallbackCell)
4490
+ ? getCellsInGridRect(selectionRef.current.tbody, selectionRef.current.sr, selectionRef.current.sc, selectionRef.current.er, selectionRef.current.ec)
4491
+ : fallbackCell ? [fallbackCell] : [];
4492
+ const positions = targetCells
4493
+ .map((target) => getCellPosition(target))
4494
+ .filter((position) => Boolean(position));
4495
+ const table = positions[0]?.table;
4496
+ if (table && positions.every((position) => position.table === table)) {
4497
+ const start = {
4498
+ row: Math.min(...positions.map((position) => position.rIdx)),
4499
+ column: Math.min(...positions.map((position) => position.cIdx)),
4500
+ };
4501
+ const end = {
4502
+ row: Math.max(...positions.map((position) => position.rIdx)),
4503
+ column: Math.max(...positions.map((position) => position.cIdx)),
4504
+ };
4505
+ clearSelectionDecor();
4506
+ const replacement = editorControllerRef.current.bindRoot(editableRef.current).executeTableCommand(table, {
4507
+ id: "table.cell.style.set",
4508
+ input: { start, end, backgroundColor: hex, textColor: readableColor || undefined },
4509
+ });
4510
+ if (replacement) {
4511
+ const nextCell = getTableGrid(replacement.tBodies[0]).grid[start.row]?.[start.column];
4512
+ if (nextCell)
4513
+ moveCaretToCell(nextCell, false);
4514
+ addTableResizeHandles();
4515
+ return;
4516
+ }
4517
+ }
4542
4518
  const applyFill = (cell) => {
4543
4519
  cell.style.background = hex;
4544
4520
  if (readableColor)
4545
4521
  cell.style.color = readableColor;
4546
4522
  };
4547
- if (explicitCells?.length) {
4548
- explicitCells.forEach(applyFill);
4549
- return;
4550
- }
4551
- const sel = shouldUseTableSelection(fallbackCell) ? selectionRef.current : null;
4552
- if (sel) {
4553
- const cells = getCellsInGridRect(sel.tbody, sel.sr, sel.sc, sel.er, sel.ec);
4554
- clearSelectionDecor();
4555
- cells.forEach(applyFill);
4556
- }
4557
- else if (fallbackCell) {
4558
- clearSelectionDecor();
4559
- applyFill(fallbackCell);
4560
- }
4523
+ clearSelectionDecor();
4524
+ targetCells.forEach(applyFill);
4561
4525
  };
4562
4526
  const tableCellFillHex = (cell) => {
4563
4527
  const storedSelectionBackground = cell.__rtePrevBg;
@@ -4580,18 +4544,35 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4580
4544
  return allSame ? first : tableCellFillHex(cell);
4581
4545
  };
4582
4546
  const toggleBorderSelection = (fallbackCell) => {
4547
+ const targetCells = shouldUseTableSelection(fallbackCell)
4548
+ ? getCellsInGridRect(selectionRef.current.tbody, selectionRef.current.sr, selectionRef.current.sc, selectionRef.current.er, selectionRef.current.ec)
4549
+ : fallbackCell ? [fallbackCell] : [];
4550
+ const positions = targetCells
4551
+ .map((target) => getCellPosition(target))
4552
+ .filter((position) => Boolean(position));
4553
+ const table = positions[0]?.table;
4554
+ if (table && positions.every((position) => position.table === table)) {
4555
+ const start = {
4556
+ row: Math.min(...positions.map((position) => position.rIdx)),
4557
+ column: Math.min(...positions.map((position) => position.cIdx)),
4558
+ };
4559
+ const end = {
4560
+ row: Math.max(...positions.map((position) => position.rIdx)),
4561
+ column: Math.max(...positions.map((position) => position.cIdx)),
4562
+ };
4563
+ clearSelectionDecor();
4564
+ if (editorControllerRef.current.bindRoot(editableRef.current).executeTableCommand(table, {
4565
+ id: "table.cell.border.toggle",
4566
+ input: { start, end },
4567
+ }))
4568
+ return;
4569
+ }
4583
4570
  const applyToggle = (cell) => {
4584
4571
  const cur = cell.style.border;
4585
4572
  cell.style.border =
4586
4573
  cur && cur !== "none" ? "none" : "1px solid #d1d5db";
4587
4574
  };
4588
- const sel = shouldUseTableSelection(fallbackCell) ? selectionRef.current : null;
4589
- if (sel) {
4590
- getCellsInGridRect(sel.tbody, sel.sr, sel.sc, sel.er, sel.ec).forEach(applyToggle);
4591
- }
4592
- else if (fallbackCell) {
4593
- applyToggle(fallbackCell);
4594
- }
4575
+ targetCells.forEach(applyToggle);
4595
4576
  };
4596
4577
  const runTableCellAction = (cell, action) => {
4597
4578
  pushEditorHistory();
@@ -4690,7 +4671,14 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4690
4671
  }
4691
4672
  };
4692
4673
  const handleTableResizeEnd = () => {
4693
- if (tableResizeRef.current) {
4674
+ const resize = tableResizeRef.current;
4675
+ if (resize) {
4676
+ const size = resize.type === "column"
4677
+ ? Number.parseFloat(resize.cells[0]?.style.width || "") || resize.startSize
4678
+ : Number.parseFloat(resize.table.rows[resize.index]?.style.height || "") || resize.startSize;
4679
+ editorControllerRef.current.bindRoot(editableRef.current).executeTableCommand(resize.table, resize.type === "column"
4680
+ ? { id: "table.column.width.set", input: { index: resize.index, widthPx: size } }
4681
+ : { id: "table.row.height.set", input: { index: resize.index, heightPx: size } });
4694
4682
  tableResizeRef.current = null;
4695
4683
  document.body.style.cursor = '';
4696
4684
  document.body.style.userSelect = '';
@@ -5005,6 +4993,8 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5005
4993
  return sibling;
5006
4994
  };
5007
4995
  const moveSelectedBlocks = (direction) => {
4996
+ if (!moveFeature)
4997
+ return false;
5008
4998
  const editor = editableRef.current;
5009
4999
  const range = getSelectionRangeInEditor();
5010
5000
  if (!editor || !range || range.collapsed)
@@ -5020,6 +5010,19 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5020
5010
  const parent = blocks[0].parentElement;
5021
5011
  if (!parent || blocks.some((block) => block.parentElement !== parent))
5022
5012
  return false;
5013
+ const coreResult = executeDomMoveCommand(blocks, direction, pushEditorHistory);
5014
+ if (coreResult === false)
5015
+ return true;
5016
+ if (coreResult) {
5017
+ const movedRange = document.createRange();
5018
+ movedRange.setStartBefore(coreResult[0]);
5019
+ movedRange.setEndAfter(coreResult[coreResult.length - 1]);
5020
+ safeSelectRange(movedRange);
5021
+ savedRangeRef.current = movedRange.cloneRange();
5022
+ handleInput();
5023
+ requestAnimationFrame(updateActiveState);
5024
+ return true;
5025
+ }
5023
5026
  pushEditorHistory();
5024
5027
  if (direction === "up") {
5025
5028
  const previous = elementSibling(blocks[0], "previous");
@@ -5053,6 +5056,8 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5053
5056
  return true;
5054
5057
  };
5055
5058
  const moveCurrentElement = (direction) => {
5059
+ if (!moveFeature)
5060
+ return;
5056
5061
  restoreSavedSelection();
5057
5062
  const editor = editableRef.current;
5058
5063
  const range = getSelectionRangeInEditor();
@@ -5077,6 +5082,23 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5077
5082
  cell.appendChild(paragraph);
5078
5083
  target = paragraph;
5079
5084
  }
5085
+ if (target.tagName.toLowerCase() !== "li") {
5086
+ const coreResult = executeDomMoveCommand([target], direction, () => {
5087
+ if (!historyPushed)
5088
+ pushEditorHistory();
5089
+ historyPushed = true;
5090
+ });
5091
+ if (coreResult === false)
5092
+ return;
5093
+ if (coreResult) {
5094
+ focusElementEnd(target);
5095
+ setSelectedImage(target.tagName === "IMG" ? target : target.querySelector("img"));
5096
+ scheduleImageOverlay();
5097
+ handleInput();
5098
+ requestAnimationFrame(updateActiveState);
5099
+ return;
5100
+ }
5101
+ }
5080
5102
  if (target.tagName.toLowerCase() === "li") {
5081
5103
  const list = target.parentElement;
5082
5104
  if (!list)
@@ -5196,6 +5218,70 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5196
5218
  event.preventDefault();
5197
5219
  }
5198
5220
  };
5221
+ const executePluginCommand = (commandId, input) => {
5222
+ const editor = editableRef.current;
5223
+ if (!editor || readOnly)
5224
+ return false;
5225
+ const before = editor.innerHTML;
5226
+ pushEditorHistory();
5227
+ const result = editorControllerRef.current
5228
+ .bindRoot(editor)
5229
+ .execute(commandId, input);
5230
+ if (!result) {
5231
+ editorControllerRef.current.discardLastHistorySnapshot(before);
5232
+ return false;
5233
+ }
5234
+ handleInput();
5235
+ requestAnimationFrame(updateActiveState);
5236
+ return true;
5237
+ };
5238
+ const executePluginToolbarContribution = (contribution) => executePluginCommand(contribution.commandId, contribution.input);
5239
+ const pluginContributionContext = {
5240
+ root: editableRef.current,
5241
+ selection: typeof window === "undefined" ? null : window.getSelection(),
5242
+ readOnly,
5243
+ canExecute: (commandId, input) => editorControllerRef.current.bindRoot(editableRef.current).canExecute(commandId, input),
5244
+ };
5245
+ const isPluginContributionVisible = (contribution) => {
5246
+ try {
5247
+ return contribution.isVisible?.(pluginContributionContext) ?? true;
5248
+ }
5249
+ catch {
5250
+ return false;
5251
+ }
5252
+ };
5253
+ const isPluginContributionEnabled = (contribution) => {
5254
+ if (readOnly)
5255
+ return false;
5256
+ try {
5257
+ if (contribution.isEnabled && !contribution.isEnabled(pluginContributionContext))
5258
+ return false;
5259
+ return !editableRef.current || pluginContributionContext.canExecute(contribution.commandId, contribution.input);
5260
+ }
5261
+ catch {
5262
+ return false;
5263
+ }
5264
+ };
5265
+ const isPluginContributionActive = (contribution) => {
5266
+ try {
5267
+ return contribution.isActive?.(pluginContributionContext) ?? false;
5268
+ }
5269
+ catch {
5270
+ return false;
5271
+ }
5272
+ };
5273
+ const isPluginShortcutAvailable = (shortcut) => {
5274
+ if (readOnly)
5275
+ return false;
5276
+ try {
5277
+ return (shortcut.isVisible?.(pluginContributionContext) ?? true) &&
5278
+ (shortcut.isEnabled?.(pluginContributionContext) ?? true) &&
5279
+ pluginContributionContext.canExecute(shortcut.commandId, shortcut.input);
5280
+ }
5281
+ catch {
5282
+ return false;
5283
+ }
5284
+ };
5199
5285
  const editorClass = `srte-editor${theme === 'dark' ? ' srte-dark' : ''}${className ? ' ' + className : ''}`;
5200
5286
  return (_jsxs("div", { className: editorClass, style: {
5201
5287
  border: "1px solid var(--srte-border)",
@@ -5256,19 +5342,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5256
5342
  }
5257
5343
  }
5258
5344
  e.currentTarget.value = "";
5259
- } })), _jsx("input", { ref: pdfInputRef, type: "file", accept: "application/pdf", style: { display: "none" }, onChange: (e) => {
5260
- handlePdfFiles(e.currentTarget.files);
5261
- e.currentTarget.value = "";
5262
- } }), _jsx("input", { ref: docxInputRef, type: "file", accept: ".docx", style: { display: "none" }, onChange: (e) => {
5263
- handleDocxFiles(e.currentTarget.files);
5264
- e.currentTarget.value = "";
5265
- } }), _jsx("input", { ref: htmlInputRef, type: "file", accept: ".html,.htm,text/html", style: { display: "none" }, onChange: (e) => {
5266
- importTextFile(e.currentTarget.files, "html");
5267
- e.currentTarget.value = "";
5268
- } }), _jsx("input", { ref: mdInputRef, type: "file", accept: ".md,.markdown,text/markdown,text/plain", style: { display: "none" }, onChange: (e) => {
5269
- importTextFile(e.currentTarget.files, "md");
5270
- e.currentTarget.value = "";
5271
- } }), _jsxs(ToolbarGroup, { label: "History", children: [_jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Undo", title: "Undo", onClick: () => exec("undo"), children: _jsx(ToolbarIcon, { name: "undo" }) }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Redo", title: "Redo", onClick: () => exec("redo"), children: _jsx(ToolbarIcon, { name: "redo" }) })] }), _jsxs("select", { value: currentBlockType, onPointerDown: preserveEditorSelection, onMouseDown: preserveEditorSelection, onChange: (e) => {
5345
+ } })), _jsxs(ToolbarGroup, { label: "History", children: [_jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Undo", title: "Undo", onClick: () => exec("undo"), children: _jsx(ToolbarIcon, { name: "undo" }) }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Redo", title: "Redo", onClick: () => exec("redo"), children: _jsx(ToolbarIcon, { name: "redo" }) })] }), blockTypeFeature && _jsxs("select", { value: currentBlockType, onPointerDown: preserveEditorSelection, onMouseDown: preserveEditorSelection, onChange: (e) => {
5272
5346
  const val = e.target.value;
5273
5347
  if (val === "p")
5274
5348
  applyFormatBlock("<p>");
@@ -5291,56 +5365,57 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5291
5365
  borderRadius: 6,
5292
5366
  background: "var(--srte-input-bg)",
5293
5367
  color: "var(--srte-input-text)",
5294
- }, children: [_jsx("option", { value: "mixed", disabled: true, children: "Mixed" }), _jsx("option", { value: "p", children: "Paragraph" }), _jsx("option", { value: "h1", children: "Heading 1" }), _jsx("option", { value: "h2", children: "Heading 2" }), _jsx("option", { value: "h3", children: "Heading 3" }), _jsx("option", { value: "h4", children: "Heading 4" }), _jsx("option", { value: "h5", children: "Heading 5" }), _jsx("option", { value: "h6", children: "Heading 6" })] }), _jsx("button", { type: "button", className: `srte-tool-button${activeState.bold ? " srte-active" : ""}`, title: "Bold", onClick: () => exec("bold"), "aria-pressed": activeState.bold, style: activeButtonStyle(activeState.bold), children: _jsx("span", { style: { fontWeight: 700 }, children: "B" }) }), _jsx("button", { type: "button", className: `srte-tool-button${activeState.italic ? " srte-active" : ""}`, title: "Italic", onClick: () => exec("italic"), "aria-pressed": activeState.italic, style: activeButtonStyle(activeState.italic, { fontStyle: "italic" }), children: "I" }), _jsx("button", { type: "button", className: `srte-tool-button${activeState.underline ? " srte-active" : ""}`, title: "Underline", onClick: () => exec("underline"), "aria-pressed": activeState.underline, style: activeButtonStyle(activeState.underline, { textDecoration: "underline" }), children: "U" }), _jsx("button", { type: "button", className: `srte-tool-button${activeState.strikeThrough ? " srte-active" : ""}`, title: "Strikethrough", onClick: () => exec("strikeThrough"), "aria-pressed": activeState.strikeThrough, style: activeButtonStyle(activeState.strikeThrough, { textDecoration: "line-through" }), children: "S" }), showFontSize && (_jsxs("select", { value: currentFontSize, onPointerDown: preserveEditorSelection, onMouseDown: preserveEditorSelection, onChange: (e) => applyFontSize(e.target.value), title: "Font Size", style: {
5295
- height: 32,
5296
- padding: "0 8px",
5297
- border: "1px solid var(--srte-input-border)",
5298
- borderRadius: 6,
5299
- background: "var(--srte-input-bg)",
5300
- color: "var(--srte-input-text)",
5301
- }, children: [_jsx("option", { value: "", disabled: true, children: "Size" }), currentFontSize && !["8", "9", "10", "11", "12", "14", "16", "18", "24", "30", "36", "48", "60", "72", "96"].includes(currentFontSize) && (_jsx("option", { value: currentFontSize, children: currentFontSize })), _jsx("option", { value: "8", children: "8" }), _jsx("option", { value: "9", children: "9" }), _jsx("option", { value: "10", children: "10" }), _jsx("option", { value: "11", children: "11" }), _jsx("option", { value: "12", children: "12" }), _jsx("option", { value: "14", children: "14" }), _jsx("option", { value: "16", children: "16" }), _jsx("option", { value: "18", children: "18" }), _jsx("option", { value: "24", children: "24" }), _jsx("option", { value: "30", children: "30" }), _jsx("option", { value: "36", children: "36" }), _jsx("option", { value: "48", children: "48" }), _jsx("option", { value: "60", children: "60" }), _jsx("option", { value: "72", children: "72" }), _jsx("option", { value: "96", children: "96" })] })), preserveFontFamily && (_jsxs("select", { value: currentFont, onMouseDown: () => {
5302
- const sel = window.getSelection();
5303
- if (sel && sel.rangeCount > 0) {
5304
- const range = sel.getRangeAt(0);
5305
- const editor = editableRef.current;
5306
- if (editor && editor.contains(range.commonAncestorContainer) && !range.collapsed) {
5307
- savedRangeRef.current = range.cloneRange();
5308
- }
5309
- }
5310
- }, onChange: (e) => applyFontFamily(e.target.value), title: "Font Family", style: {
5311
- height: 32,
5312
- padding: "0 8px",
5313
- border: "1px solid var(--srte-input-border)",
5314
- borderRadius: 6,
5315
- background: "var(--srte-input-bg)",
5316
- color: "var(--srte-input-text)",
5317
- maxWidth: 100,
5318
- }, children: [_jsx("option", { value: "", disabled: true, children: "Font" }), fonts.map((f) => (_jsx("option", { value: f.value, children: f.name }, f.value)))] })), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Text color", title: "Text Color", onMouseDown: preserveEditorSelection, onClick: () => {
5319
- setColorPickerType('text');
5320
- setShowColorPicker(true);
5321
- }, style: {
5322
- height: 32,
5323
- minWidth: 32,
5324
- padding: "0 8px",
5325
- border: "1px solid var(--srte-input-border)",
5326
- borderRadius: 6,
5327
- background: "var(--srte-input-bg)",
5328
- color: "var(--srte-input-text)",
5329
- position: "relative",
5330
- }, children: _jsx("span", { style: { fontWeight: 700, borderBottom: `3px solid ${currentTextColor}`, lineHeight: 1 }, children: "A" }) }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Background color", title: "Background Color", onMouseDown: preserveEditorSelection, onClick: () => {
5331
- setColorPickerType('background');
5332
- setShowColorPicker(true);
5333
- }, style: {
5334
- height: 32,
5335
- minWidth: 32,
5336
- padding: "0 8px",
5337
- border: "1px solid var(--srte-input-border)",
5338
- borderRadius: 6,
5339
- background: "var(--srte-input-bg)",
5340
- color: "var(--srte-input-text)",
5341
- }, children: _jsx("span", { style: { fontWeight: 700, padding: "1px 4px", background: currentBackgroundColor, color: readableTextColorForBackground(currentBackgroundColor) || "currentColor", borderRadius: 3 }, children: "A" }) }), _jsxs("button", { type: "button", className: `srte-tool-button${activeState.subscript ? " srte-active" : ""}`, title: "Subscript", onMouseDown: preserveEditorSelection, onClick: () => toggleInlineScript("subscript"), "aria-pressed": activeState.subscript, style: activeButtonStyle(activeState.subscript), children: ["X", _jsx("sub", { children: "2" })] }), _jsxs("button", { type: "button", className: `srte-tool-button${activeState.superscript ? " srte-active" : ""}`, title: "Superscript", onMouseDown: preserveEditorSelection, onClick: () => toggleInlineScript("superscript"), "aria-pressed": activeState.superscript, style: activeButtonStyle(activeState.superscript), children: ["X", _jsx("sup", { children: "2" })] }), [
5368
+ }, children: [_jsx("option", { value: "mixed", disabled: true, children: "Mixed" }), _jsx("option", { value: "p", children: "Paragraph" }), _jsx("option", { value: "h1", children: "Heading 1" }), _jsx("option", { value: "h2", children: "Heading 2" }), _jsx("option", { value: "h3", children: "Heading 3" }), _jsx("option", { value: "h4", children: "Heading 4" }), _jsx("option", { value: "h5", children: "Heading 5" }), _jsx("option", { value: "h6", children: "Heading 6" })] }), basicFormattingFeature && _jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: `srte-tool-button${activeState.bold ? " srte-active" : ""}`, title: "Bold", onClick: () => exec("bold"), "aria-pressed": activeState.bold, style: activeButtonStyle(activeState.bold), children: _jsx("span", { style: { fontWeight: 700 }, children: "B" }) }), _jsx("button", { type: "button", className: `srte-tool-button${activeState.italic ? " srte-active" : ""}`, title: "Italic", onClick: () => exec("italic"), "aria-pressed": activeState.italic, style: activeButtonStyle(activeState.italic, { fontStyle: "italic" }), children: "I" }), _jsx("button", { type: "button", className: `srte-tool-button${activeState.underline ? " srte-active" : ""}`, title: "Underline", onClick: () => exec("underline"), "aria-pressed": activeState.underline, style: activeButtonStyle(activeState.underline, { textDecoration: "underline" }), children: "U" }), _jsx("button", { type: "button", className: `srte-tool-button${activeState.strikeThrough ? " srte-active" : ""}`, title: "Strikethrough", onClick: () => exec("strikeThrough"), "aria-pressed": activeState.strikeThrough, style: activeButtonStyle(activeState.strikeThrough, { textDecoration: "line-through" }), children: "S" }), showFontSize && (_jsxs("select", { value: currentFontSize, onPointerDown: preserveEditorSelection, onMouseDown: preserveEditorSelection, onChange: (e) => applyFontSize(e.target.value), title: "Font Size", style: {
5369
+ height: 32,
5370
+ padding: "0 8px",
5371
+ border: "1px solid var(--srte-input-border)",
5372
+ borderRadius: 6,
5373
+ background: "var(--srte-input-bg)",
5374
+ color: "var(--srte-input-text)",
5375
+ }, children: [_jsx("option", { value: "", disabled: true, children: "Size" }), currentFontSize && !["8", "9", "10", "11", "12", "14", "16", "18", "24", "30", "36", "48", "60", "72", "96"].includes(currentFontSize) && (_jsx("option", { value: currentFontSize, children: currentFontSize })), _jsx("option", { value: "8", children: "8" }), _jsx("option", { value: "9", children: "9" }), _jsx("option", { value: "10", children: "10" }), _jsx("option", { value: "11", children: "11" }), _jsx("option", { value: "12", children: "12" }), _jsx("option", { value: "14", children: "14" }), _jsx("option", { value: "16", children: "16" }), _jsx("option", { value: "18", children: "18" }), _jsx("option", { value: "24", children: "24" }), _jsx("option", { value: "30", children: "30" }), _jsx("option", { value: "36", children: "36" }), _jsx("option", { value: "48", children: "48" }), _jsx("option", { value: "60", children: "60" }), _jsx("option", { value: "72", children: "72" }), _jsx("option", { value: "96", children: "96" })] })), preserveFontFamily && (_jsxs("select", { value: currentFont, onMouseDown: () => {
5376
+ const sel = window.getSelection();
5377
+ if (sel && sel.rangeCount > 0) {
5378
+ const range = sel.getRangeAt(0);
5379
+ const editor = editableRef.current;
5380
+ if (editor && editor.contains(range.commonAncestorContainer) && !range.collapsed) {
5381
+ savedRangeRef.current = range.cloneRange();
5382
+ }
5383
+ }
5384
+ }, onChange: (e) => applyFontFamily(e.target.value), title: "Font Family", style: {
5385
+ height: 32,
5386
+ padding: "0 8px",
5387
+ border: "1px solid var(--srte-input-border)",
5388
+ borderRadius: 6,
5389
+ background: "var(--srte-input-bg)",
5390
+ color: "var(--srte-input-text)",
5391
+ maxWidth: 100,
5392
+ }, children: [_jsx("option", { value: "", disabled: true, children: "Font" }), fonts.map((f) => (_jsx("option", { value: f.value, children: f.name }, f.value)))] })), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Text color", title: "Text Color", onMouseDown: preserveEditorSelection, onClick: () => {
5393
+ setColorPickerType('text');
5394
+ setShowColorPicker(true);
5395
+ }, style: {
5396
+ height: 32,
5397
+ minWidth: 32,
5398
+ padding: "0 8px",
5399
+ border: "1px solid var(--srte-input-border)",
5400
+ borderRadius: 6,
5401
+ background: "var(--srte-input-bg)",
5402
+ color: "var(--srte-input-text)",
5403
+ position: "relative",
5404
+ }, children: _jsx("span", { style: { fontWeight: 700, borderBottom: `3px solid ${currentTextColor}`, lineHeight: 1 }, children: "A" }) }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Background color", title: "Background Color", onMouseDown: preserveEditorSelection, onClick: () => {
5405
+ setColorPickerType('background');
5406
+ setShowColorPicker(true);
5407
+ }, style: {
5408
+ height: 32,
5409
+ minWidth: 32,
5410
+ padding: "0 8px",
5411
+ border: "1px solid var(--srte-input-border)",
5412
+ borderRadius: 6,
5413
+ background: "var(--srte-input-bg)",
5414
+ color: "var(--srte-input-text)",
5415
+ }, children: _jsx("span", { style: { fontWeight: 700, padding: "1px 4px", background: currentBackgroundColor, color: readableTextColorForBackground(currentBackgroundColor) || "currentColor", borderRadius: 3 }, children: "A" }) }), _jsxs("button", { type: "button", className: `srte-tool-button${activeState.subscript ? " srte-active" : ""}`, title: "Subscript", onMouseDown: preserveEditorSelection, onClick: () => toggleInlineScript("subscript"), "aria-pressed": activeState.subscript, style: activeButtonStyle(activeState.subscript), children: ["X", _jsx("sub", { children: "2" })] }), _jsxs("button", { type: "button", className: `srte-tool-button${activeState.superscript ? " srte-active" : ""}`, title: "Superscript", onMouseDown: preserveEditorSelection, onClick: () => toggleInlineScript("superscript"), "aria-pressed": activeState.superscript, style: activeButtonStyle(activeState.superscript), children: ["X", _jsx("sup", { children: "2" })] })] }), [
5342
5416
  {
5343
5417
  key: "check",
5418
+ enabled: checklistFeature,
5344
5419
  icon: "checklist",
5345
5420
  title: "Checklist",
5346
5421
  active: activeState.checklist,
@@ -5349,21 +5424,37 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5349
5424
  },
5350
5425
  {
5351
5426
  key: "bullet",
5427
+ enabled: listFeature,
5352
5428
  icon: "bullets",
5353
5429
  title: "Bulleted list",
5354
5430
  active: activeState.unorderedList,
5355
- action: () => applyListStyle("bullet:disc"),
5356
- options: [["bullet:disc", "• Disc"], ["bullet:circle", "○ Circle"], ["bullet:square", "▪ Square"]],
5431
+ action: () => toggleList("ul"),
5432
+ options: [
5433
+ ...SMART_LIST_PRESETS.filter((preset) => preset.kind === "bullet")
5434
+ .map((preset) => [`bullet-preset:${preset.id}`, preset.label, false]),
5435
+ ["bullet:disc", "• Disc", true],
5436
+ ["bullet:circle", "○ Circle", true],
5437
+ ["bullet:square", "▪ Square", true],
5438
+ ],
5357
5439
  },
5358
5440
  {
5359
5441
  key: "ordered",
5442
+ enabled: listFeature,
5360
5443
  icon: "numbers",
5361
5444
  title: "Numbered list",
5362
5445
  active: activeState.orderedList,
5363
- action: () => applyListStyle("ordered:decimal"),
5364
- options: [["ordered:decimal", "1. 2. 3."], ["ordered:lower-alpha", "a. b. c."], ["ordered:upper-alpha", "A. B. C."], ["ordered:lower-roman", "i. ii. iii."], ["ordered:upper-roman", "I. II. III."]],
5446
+ action: () => toggleList("ol"),
5447
+ options: [
5448
+ ...SMART_LIST_PRESETS.filter((preset) => preset.kind === "ordered")
5449
+ .map((preset) => [`ordered-preset:${preset.id}`, preset.label, false]),
5450
+ ["ordered:decimal", "1. 2. 3.", true],
5451
+ ["ordered:lower-alpha", "a. b. c.", true],
5452
+ ["ordered:upper-alpha", "A. B. C.", true],
5453
+ ["ordered:lower-roman", "i. ii. iii.", true],
5454
+ ["ordered:upper-roman", "I. II. III.", true],
5455
+ ],
5365
5456
  },
5366
- ].map((control) => (_jsxs("span", { className: "srte-split-control", children: [_jsx("button", { type: "button", className: `srte-tool-button${control.active ? " srte-active" : ""}`, title: control.title, onPointerDown: preserveEditorSelection, onClick: control.action, "aria-pressed": control.active, children: _jsx(ToolbarIcon, { name: control.icon }) }), _jsxs("select", { defaultValue: "", "aria-label": `${control.title} styles`, title: `${control.title} styles`, onPointerDown: preserveEditorSelection, onMouseDown: preserveEditorSelection, onChange: (event) => {
5457
+ ].filter((control) => control.enabled).map((control) => (_jsxs("span", { className: "srte-split-control", children: [_jsx("button", { type: "button", className: `srte-tool-button${control.active ? " srte-active" : ""}`, title: control.title, onPointerDown: preserveEditorSelection, onClick: control.action, "aria-pressed": control.active, children: _jsx(ToolbarIcon, { name: control.icon }) }), _jsxs("select", { defaultValue: "", "aria-label": `${control.title} styles`, title: `${control.title} styles`, onPointerDown: preserveEditorSelection, onMouseDown: preserveEditorSelection, onChange: (event) => {
5367
5458
  const selected = event.currentTarget.value;
5368
5459
  if (selected === "check:plain")
5369
5460
  applyChecklist(false);
@@ -5372,7 +5463,10 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5372
5463
  else if (selected)
5373
5464
  applyListStyle(selected);
5374
5465
  event.currentTarget.value = "";
5375
- }, children: [_jsx("option", { value: "", disabled: true, children: "Style" }), control.options.map(([value, label]) => _jsx("option", { value: value, children: label }, value))] })] }, control.key))), _jsx("button", { type: "button", className: `srte-tool-button${activeState.blockquote ? " srte-active" : ""}`, title: "Blockquote", onPointerDown: preserveEditorSelection, onClick: toggleBlockquote, "aria-pressed": activeState.blockquote, children: _jsx(ToolbarIcon, { name: "quote" }) }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Special characters", title: "Special characters", onClick: () => setShowSpecialChars(true), children: _jsx(ToolbarIcon, { name: "omega" }) }), _jsx("button", { type: "button", className: `srte-tool-button${activeState.codeBlock ? " srte-active" : ""}`, title: "Code block", onPointerDown: preserveEditorSelection, onClick: toggleCodeBlock, "aria-pressed": activeState.codeBlock, children: _jsx(ToolbarIcon, { name: "code" }) }), _jsx("button", { type: "button", className: `srte-tool-button${activeState.link ? " srte-active" : ""}`, title: "Insert link", "aria-label": "Insert or edit link", "aria-pressed": activeState.link, onPointerDown: preserveEditorSelection, onClick: () => openLinkEditor(), children: _jsx(ToolbarIcon, { name: "link" }) }), (table || media || formula) && (_jsxs(ToolbarMenu, { label: "Insert", icon: "insert", priority: 2, children: [table && _jsx(MenuItem, { icon: "table", label: "Table", title: "Insert table", onClick: () => setShowTableDialog(true) }), media && _jsx(MenuItem, { icon: "image", label: "Upload image", title: "Insert image", onClick: insertImage }), media && _jsx(MenuItem, { icon: "media", label: "Media", title: "Open media", onClick: () => mediaManager ? setShowMediaManager(true) : insertImage() }), formula && _jsx(MenuItem, { icon: "formula", label: "Formula", title: "Insert formula", onClick: () => setShowFormulaDialog(true) })] })), selectedImage && (_jsxs(ToolbarMenu, { label: "Image alignment", icon: "image", children: [_jsx(MenuItem, { icon: "align-center", label: "Center image", onClick: () => {
5466
+ }, children: [_jsx("option", { value: "", disabled: true, children: "Style" }), control.options.map(([value, label, hidden]) => _jsx("option", { value: value, hidden: hidden, children: label }, value))] })] }, control.key))), blockquoteFeature && _jsx("button", { type: "button", className: `srte-tool-button${activeState.blockquote ? " srte-active" : ""}`, title: "Blockquote", onPointerDown: preserveEditorSelection, onClick: toggleBlockquote, "aria-pressed": activeState.blockquote, children: _jsx(ToolbarIcon, { name: "quote" }) }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Special characters", title: "Special characters", onClick: () => setShowSpecialChars(true), children: _jsx(ToolbarIcon, { name: "omega" }) }), codeBlockFeature && _jsx("button", { type: "button", className: `srte-tool-button${activeState.codeBlock ? " srte-active" : ""}`, title: "Code block", onPointerDown: preserveEditorSelection, onClick: toggleCodeBlock, "aria-pressed": activeState.codeBlock, children: _jsx(ToolbarIcon, { name: "code" }) }), basicFormattingFeature && _jsx("button", { type: "button", className: `srte-tool-button${activeState.link ? " srte-active" : ""}`, title: "Insert link", "aria-label": "Insert or edit link", "aria-pressed": activeState.link, onPointerDown: preserveEditorSelection, onClick: () => openLinkEditor(), children: _jsx(ToolbarIcon, { name: "link" }) }), pluginRuntime.toolbar
5467
+ .filter((contribution) => (!contribution.placement || contribution.placement === "main") &&
5468
+ isPluginContributionVisible(contribution))
5469
+ .map((contribution) => (_jsx("button", { type: "button", className: "srte-tool-button", disabled: !isPluginContributionEnabled(contribution), "aria-pressed": isPluginContributionActive(contribution), "aria-label": contribution.label, title: contribution.title || contribution.label, onPointerDown: preserveEditorSelection, onClick: () => executePluginToolbarContribution(contribution), children: contribution.icon || contribution.label.slice(0, 2) }, contribution.id))), (table || media || formula || pluginRuntime.toolbar.some((item) => item.placement === "insert")) && (_jsxs(ToolbarMenu, { label: "Insert", icon: "insert", priority: 2, children: [table && _jsx(MenuItem, { icon: "table", label: "Table", title: "Insert table", onClick: () => setShowTableDialog(true) }), media && _jsx(MenuItem, { icon: "image", label: "Upload image", title: "Insert image", onClick: insertImage }), media && _jsx(MenuItem, { icon: "media", label: "Media", title: "Open media", onClick: () => mediaManager ? setShowMediaManager(true) : insertImage() }), formula && _jsx(MenuItem, { icon: "formula", label: "Formula", title: "Insert formula", onClick: () => setShowFormulaDialog(true) }), pluginRuntime.toolbar.filter((item) => item.placement === "insert" && isPluginContributionVisible(item)).map((contribution) => (_jsxs("button", { type: "button", role: "menuitem", className: "srte-menu-item", disabled: !isPluginContributionEnabled(contribution), title: contribution.title || contribution.label, onClick: () => executePluginToolbarContribution(contribution), children: [_jsx("span", { "aria-hidden": "true", children: contribution.icon || "+" }), _jsx("span", { children: contribution.label })] }, contribution.id)))] })), selectedImage && (_jsxs(ToolbarMenu, { label: "Image alignment", icon: "image", children: [_jsx(MenuItem, { icon: "align-center", label: "Center image", onClick: () => {
5376
5470
  selectedImage.style.display = "block";
5377
5471
  selectedImage.style.margin = "0 auto";
5378
5472
  selectedImage.style.float = "none";
@@ -5390,17 +5484,10 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5390
5484
  selectedImage.style.margin = "0 0 8px 8px";
5391
5485
  scheduleImageOverlay();
5392
5486
  handleInput();
5393
- } })] })), _jsxs(ToolbarGroup, { label: "Document", priority: 3, children: [_jsxs(ToolbarMenu, { label: loadingPdf || loadingDocx ? "Importing document" : "Import document", icon: "import", children: [_jsx(MenuItem, { icon: "import", label: "PDF (.pdf)", disabled: loadingPdf || loadingDocx, onClick: () => pdfInputRef.current?.click() }), _jsx(MenuItem, { icon: "import", label: "Microsoft Word (.docx)", disabled: loadingPdf || loadingDocx, onClick: () => docxInputRef.current?.click() }), _jsx(MenuItem, { icon: "import", label: "HTML (.html)", disabled: loadingPdf || loadingDocx, onClick: () => htmlInputRef.current?.click() }), _jsx(MenuItem, { icon: "import", label: "Markdown (.md)", disabled: loadingPdf || loadingDocx, onClick: () => mdInputRef.current?.click() })] }), _jsxs(ToolbarMenu, { label: "Export document", icon: "export", children: [_jsx(MenuItem, { icon: "export", label: "PDF", onClick: exportPdf }), _jsx(MenuItem, { icon: "export", label: "Microsoft Word (.docx)", onClick: () => void exportDocx() }), _jsx(MenuItem, { icon: "export", label: "HTML", onClick: exportHtml }), _jsx(MenuItem, { icon: "export", label: "Markdown", onClick: exportMarkdown })] }), _jsxs("select", { className: "srte-command-proxy", "aria-hidden": "true", tabIndex: -1, defaultValue: "", "aria-label": "Import document", title: "Import document", disabled: loadingPdf || loadingDocx, onChange: (event) => {
5487
+ } })] })), formatRuntime.formats.length > 0 && _jsxs(ToolbarGroup, { label: "Document", priority: 3, children: [formatRuntime.imports.length > 0 && _jsx(ToolbarMenu, { label: loadingPdf || loadingDocx ? "Importing document" : "Import document", icon: "import", children: formatRuntime.imports.map((format) => _jsx(MenuItem, { icon: "import", label: `${format.label} (.${format.extension})`, disabled: loadingPdf || loadingDocx, onClick: () => openFormatImport(format.id) }, format.id)) }), formatRuntime.exports.length > 0 && _jsx(ToolbarMenu, { label: "Export document", icon: "export", children: formatRuntime.exports.map((format) => _jsx(MenuItem, { icon: "export", label: format.label, onClick: () => void runFormatExport(format.id) }, format.id)) }), _jsxs("select", { className: "srte-command-proxy", "aria-hidden": "true", tabIndex: -1, defaultValue: "", "aria-label": "Import document", title: "Import document", disabled: loadingPdf || loadingDocx, onChange: (event) => {
5394
5488
  const format = event.currentTarget.value;
5395
5489
  event.currentTarget.value = "";
5396
- if (format === "pdf")
5397
- pdfInputRef.current?.click();
5398
- else if (format === "docx")
5399
- docxInputRef.current?.click();
5400
- else if (format === "html")
5401
- htmlInputRef.current?.click();
5402
- else if (format === "markdown")
5403
- mdInputRef.current?.click();
5490
+ openFormatImport(format);
5404
5491
  }, style: {
5405
5492
  height: 32,
5406
5493
  padding: "0 8px",
@@ -5409,17 +5496,10 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5409
5496
  background: "var(--srte-input-bg)",
5410
5497
  color: "var(--srte-input-text)",
5411
5498
  opacity: loadingPdf || loadingDocx ? 0.6 : 1,
5412
- }, children: [_jsx("option", { value: "", disabled: true, children: loadingPdf || loadingDocx ? "Importing…" : "Import…" }), _jsx("option", { value: "pdf", children: "PDF (.pdf)" }), _jsx("option", { value: "docx", children: "Word (.docx)" }), _jsx("option", { value: "html", children: "HTML (.html)" }), _jsx("option", { value: "markdown", children: "Markdown (.md)" })] }), _jsxs("select", { className: "srte-command-proxy", "aria-hidden": "true", tabIndex: -1, defaultValue: "", "aria-label": "Export document", title: "Export document", onChange: (event) => {
5499
+ }, children: [_jsx("option", { value: "", disabled: true, children: loadingPdf || loadingDocx ? "Importing…" : "Import…" }), formatRuntime.imports.map((format) => _jsxs("option", { value: format.id, children: [proxyFormatLabel(format), " (.", format.extension, ")"] }, format.id))] }), _jsxs("select", { className: "srte-command-proxy", "aria-hidden": "true", tabIndex: -1, defaultValue: "", "aria-label": "Export document", title: "Export document", onChange: (event) => {
5413
5500
  const format = event.currentTarget.value;
5414
5501
  event.currentTarget.value = "";
5415
- if (format === "html")
5416
- exportHtml();
5417
- else if (format === "markdown")
5418
- exportMarkdown();
5419
- else if (format === "docx")
5420
- void exportDocx();
5421
- else if (format === "pdf")
5422
- exportPdf();
5502
+ void runFormatExport(format);
5423
5503
  }, style: {
5424
5504
  height: 32,
5425
5505
  padding: "0 8px",
@@ -5427,7 +5507,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5427
5507
  borderRadius: 6,
5428
5508
  background: "var(--srte-input-bg)",
5429
5509
  color: "var(--srte-input-text)",
5430
- }, children: [_jsx("option", { value: "", disabled: true, children: "Export\u2026" }), _jsx("option", { value: "html", children: "HTML (.html)" }), _jsx("option", { value: "markdown", children: "Markdown (.md)" }), _jsx("option", { value: "docx", children: "Word (.docx)" }), _jsx("option", { value: "pdf", children: "PDF (.pdf)" })] })] }), _jsx(ToolbarMenu, { label: "Text alignment", icon: currentAlignment === "center" ? "align-center" : currentAlignment === "right" ? "align-right" : currentAlignment === "justify" ? "justify" : "align-left", active: currentAlignment !== "left", children: ["left", "center", "right", "justify"].map((alignment) => (_jsx(MenuItem, { icon: alignment === "left" ? "align-left" : alignment === "center" ? "align-center" : alignment === "right" ? "align-right" : "justify", label: alignment === "justify" ? "Justify" : `Align ${alignment}`, title: alignment === "justify" ? "Justify" : `Align ${alignment}`, active: currentAlignment === alignment, onClick: () => applyTextAlignment(alignment) }, alignment))) }), _jsxs(ToolbarMenu, { label: "Move and indent", icon: "move", priority: 2, children: [_jsx(MenuItem, { icon: "up", label: "Move block up", title: "Move selected block up", onClick: () => moveCurrentElement("up") }), _jsx(MenuItem, { icon: "down", label: "Move block down", title: "Move selected block down", onClick: () => moveCurrentElement("down") }), _jsx("div", { className: "srte-menu-separator", role: "separator" }), _jsx(MenuItem, { icon: "outdent", label: "Decrease indent", title: "Move selected block left", onClick: () => moveCurrentElement("left") }), _jsx(MenuItem, { icon: "indent", label: "Increase indent", title: "Move selected block right", onClick: () => moveCurrentElement("right") })] }), _jsx("div", { className: "srte-mobile-more", children: _jsxs(ToolbarMenu, { label: "More editor actions", icon: "more", children: [table && _jsx(MenuItem, { icon: "table", label: "Insert table", onClick: () => setShowTableDialog(true) }), media && _jsx(MenuItem, { icon: "image", label: "Upload image", onClick: insertImage }), media && _jsx(MenuItem, { icon: "media", label: "Media", onClick: () => mediaManager ? setShowMediaManager(true) : insertImage() }), formula && _jsx(MenuItem, { icon: "formula", label: "Insert formula", onClick: () => setShowFormulaDialog(true) }), _jsx(MenuItem, { icon: "omega", label: "Special characters", onClick: () => setShowSpecialChars(true) }), _jsx(MenuItem, { icon: "up", label: "Move block up", onClick: () => moveCurrentElement("up") }), _jsx(MenuItem, { icon: "down", label: "Move block down", onClick: () => moveCurrentElement("down") }), _jsx(MenuItem, { icon: "outdent", label: "Decrease indent", onClick: () => moveCurrentElement("left") }), _jsx(MenuItem, { icon: "indent", label: "Increase indent", onClick: () => moveCurrentElement("right") }), _jsx("div", { className: "srte-menu-separator", role: "separator" }), _jsx(MenuItem, { icon: "import", label: "Import Word document", onClick: () => docxInputRef.current?.click() }), _jsx(MenuItem, { icon: "import", label: "Import HTML", onClick: () => htmlInputRef.current?.click() }), _jsx(MenuItem, { icon: "import", label: "Import Markdown", onClick: () => mdInputRef.current?.click() }), _jsx("div", { className: "srte-menu-separator", role: "separator" }), _jsx(MenuItem, { icon: "export", label: "Export PDF", onClick: exportPdf }), _jsx(MenuItem, { icon: "export", label: "Export Word document", onClick: () => void exportDocx() }), _jsx(MenuItem, { icon: "export", label: "Export HTML", onClick: exportHtml }), _jsx(MenuItem, { icon: "export", label: "Export Markdown", onClick: exportMarkdown })] }) })] }), media && mediaManager && (_jsx(MediaManager, { open: showMediaManager, onClose: () => setShowMediaManager(false), adapter: mediaManager, onSelect: (item) => {
5510
+ }, children: [_jsx("option", { value: "", disabled: true, children: "Export\u2026" }), proxyExportFormats.map((format) => _jsxs("option", { value: format.id, children: [proxyFormatLabel(format), " (.", format.extension, ")"] }, format.id))] })] }), alignmentFeature && _jsx(ToolbarMenu, { label: "Text alignment", icon: currentAlignment === "center" ? "align-center" : currentAlignment === "right" ? "align-right" : currentAlignment === "justify" ? "justify" : "align-left", active: currentAlignment !== "left", children: ["left", "center", "right", "justify"].map((alignment) => (_jsx(MenuItem, { icon: alignment === "left" ? "align-left" : alignment === "center" ? "align-center" : alignment === "right" ? "align-right" : "justify", label: alignment === "justify" ? "Justify" : `Align ${alignment}`, title: alignment === "justify" ? "Justify" : `Align ${alignment}`, active: currentAlignment === alignment, onClick: () => applyTextAlignment(alignment) }, alignment))) }), moveFeature && _jsxs(ToolbarMenu, { label: "Move and indent", icon: "move", priority: 2, children: [_jsx(MenuItem, { icon: "up", label: "Move block up", title: "Move selected block up", onClick: () => moveCurrentElement("up") }), _jsx(MenuItem, { icon: "down", label: "Move block down", title: "Move selected block down", onClick: () => moveCurrentElement("down") }), _jsx("div", { className: "srte-menu-separator", role: "separator" }), _jsx(MenuItem, { icon: "outdent", label: "Decrease indent", title: "Move selected block left", onClick: () => moveCurrentElement("left") }), _jsx(MenuItem, { icon: "indent", label: "Increase indent", title: "Move selected block right", onClick: () => moveCurrentElement("right") })] }), _jsx("div", { className: "srte-mobile-more", children: _jsxs(ToolbarMenu, { label: "More editor actions", icon: "more", children: [table && _jsx(MenuItem, { icon: "table", label: "Insert table", onClick: () => setShowTableDialog(true) }), media && _jsx(MenuItem, { icon: "image", label: "Upload image", onClick: insertImage }), media && _jsx(MenuItem, { icon: "media", label: "Media", onClick: () => mediaManager ? setShowMediaManager(true) : insertImage() }), formula && _jsx(MenuItem, { icon: "formula", label: "Insert formula", onClick: () => setShowFormulaDialog(true) }), pluginRuntime.toolbar.filter((item) => item.placement === "more" && isPluginContributionVisible(item)).map((contribution) => (_jsxs("button", { type: "button", role: "menuitem", className: "srte-menu-item", disabled: !isPluginContributionEnabled(contribution), title: contribution.title || contribution.label, onClick: () => executePluginToolbarContribution(contribution), children: [_jsx("span", { "aria-hidden": "true", children: contribution.icon || "•" }), _jsx("span", { children: contribution.label })] }, contribution.id))), _jsx(MenuItem, { icon: "omega", label: "Special characters", onClick: () => setShowSpecialChars(true) }), moveFeature && _jsx(MenuItem, { icon: "up", label: "Move block up", onClick: () => moveCurrentElement("up") }), moveFeature && _jsx(MenuItem, { icon: "down", label: "Move block down", onClick: () => moveCurrentElement("down") }), moveFeature && _jsx(MenuItem, { icon: "outdent", label: "Decrease indent", onClick: () => moveCurrentElement("left") }), moveFeature && _jsx(MenuItem, { icon: "indent", label: "Increase indent", onClick: () => moveCurrentElement("right") }), _jsx("div", { className: "srte-menu-separator", role: "separator" }), formatRuntime.imports.map((format) => _jsx(MenuItem, { icon: "import", label: `Import ${format.label}`, onClick: () => openFormatImport(format.id) }, `mobile-import-${format.id}`)), _jsx("div", { className: "srte-menu-separator", role: "separator" }), formatRuntime.exports.map((format) => _jsx(MenuItem, { icon: "export", label: `Export ${format.label}`, onClick: () => void runFormatExport(format.id) }, `mobile-export-${format.id}`))] }) })] }), media && mediaManager && (_jsx(MediaManager, { open: showMediaManager, onClose: () => setShowMediaManager(false), adapter: mediaManager, onSelect: (item) => {
5431
5511
  if (item?.url)
5432
5512
  insertImageAtSelection(item);
5433
5513
  } })), table && showTableDialog && (_jsx("div", { style: {
@@ -5488,10 +5568,6 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5488
5568
  zIndex: 100,
5489
5569
  }, onClick: () => {
5490
5570
  setPendingImport(null);
5491
- if (pdfInputRef.current)
5492
- pdfInputRef.current.value = "";
5493
- if (docxInputRef.current)
5494
- docxInputRef.current.value = "";
5495
5571
  }, children: _jsxs("div", { style: {
5496
5572
  background: "var(--srte-modal-bg)",
5497
5573
  color: "var(--srte-modal-text)",
@@ -5500,7 +5576,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5500
5576
  maxWidth: 400,
5501
5577
  width: "90%",
5502
5578
  boxShadow: "var(--srte-menu-shadow)",
5503
- }, onClick: (e) => e.stopPropagation(), children: [_jsx("h3", { style: { margin: "0 0 12px 0", fontSize: 18, fontWeight: 600 }, children: "Import Content" }), _jsx("p", { style: { margin: "0 0 20px 0", color: "var(--srte-text-muted)", fontSize: 14 }, children: "The editor already contains content. How would you like to handle the imported document?" }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx("button", { onClick: () => processImport(pendingImport.file, pendingImport.type, 'replace'), style: {
5579
+ }, onClick: (e) => e.stopPropagation(), children: [_jsx("h3", { style: { margin: "0 0 12px 0", fontSize: 18, fontWeight: 600 }, children: "Import Content" }), _jsx("p", { style: { margin: "0 0 20px 0", color: "var(--srte-text-muted)", fontSize: 14 }, children: "The editor already contains content. How would you like to handle the imported document?" }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx("button", { onClick: () => void processFormatImport(pendingImport.definition, pendingImport.file, 'replace'), style: {
5504
5580
  padding: "8px 16px",
5505
5581
  background: "var(--srte-danger)",
5506
5582
  color: "var(--srte-on-primary)",
@@ -5509,7 +5585,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5509
5585
  cursor: "pointer",
5510
5586
  fontWeight: 500,
5511
5587
  textAlign: "left"
5512
- }, children: "Replace Check existing content (Overwrite)" }), _jsx("button", { onClick: () => processImport(pendingImport.file, pendingImport.type, 'append'), style: {
5588
+ }, children: "Replace Check existing content (Overwrite)" }), _jsx("button", { onClick: () => void processFormatImport(pendingImport.definition, pendingImport.file, 'append'), style: {
5513
5589
  padding: "8px 16px",
5514
5590
  background: "var(--srte-primary)",
5515
5591
  color: "var(--srte-on-primary)",
@@ -5520,10 +5596,6 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5520
5596
  textAlign: "left"
5521
5597
  }, children: "Append to bottom" }), _jsx("button", { onClick: () => {
5522
5598
  setPendingImport(null);
5523
- if (pdfInputRef.current)
5524
- pdfInputRef.current.value = "";
5525
- if (docxInputRef.current)
5526
- docxInputRef.current.value = "";
5527
5599
  }, style: {
5528
5600
  padding: "8px 16px",
5529
5601
  background: "var(--srte-cancel-bg)",
@@ -5886,6 +5958,14 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5886
5958
  if (item && list) {
5887
5959
  pushEditorHistory();
5888
5960
  const checked = item.dataset.checked !== "true";
5961
+ const replacement = editorControllerRef.current
5962
+ .bindRoot(editableRef.current)
5963
+ .executeChecklistItemCommand(list, item, checked);
5964
+ if (replacement) {
5965
+ syncChecklistControls(replacement);
5966
+ handleInput();
5967
+ return;
5968
+ }
5889
5969
  item.dataset.checked = checked ? "true" : "false";
5890
5970
  t.dataset.checked = checked ? "true" : "false";
5891
5971
  t.setAttribute("aria-pressed", checked ? "true" : "false");
@@ -5957,7 +6037,30 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
5957
6037
  }
5958
6038
  updateActiveState();
5959
6039
  }, onKeyDown: (e) => {
5960
- if ((e.metaKey || e.ctrlKey) && String(e.key).toLowerCase() === "k") {
6040
+ if (e.key === "Backspace" || e.key === "Delete") {
6041
+ const selection = window.getSelection();
6042
+ const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
6043
+ const atom = range
6044
+ ? adjacentInlineAtom(range, e.key === "Backspace" ? "backward" : "forward")
6045
+ : null;
6046
+ if (atom && editableRef.current) {
6047
+ pushEditorHistory();
6048
+ if (editorControllerRef.current.bindRoot(editableRef.current).deleteInlineAtom(atom)) {
6049
+ e.preventDefault();
6050
+ handleInput();
6051
+ return;
6052
+ }
6053
+ editorControllerRef.current.discardLastHistorySnapshot();
6054
+ }
6055
+ }
6056
+ const pluginShortcut = pluginRuntime.shortcuts.find((shortcut) => matchesPluginShortcut(e, shortcut) &&
6057
+ isPluginShortcutAvailable(shortcut));
6058
+ if (pluginShortcut &&
6059
+ executePluginCommand(pluginShortcut.commandId, pluginShortcut.input)) {
6060
+ e.preventDefault();
6061
+ return;
6062
+ }
6063
+ if (basicFormattingFeature && (e.metaKey || e.ctrlKey) && String(e.key).toLowerCase() === "k") {
5961
6064
  e.preventDefault();
5962
6065
  openLinkEditor();
5963
6066
  return;
@@ -6076,6 +6179,31 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
6076
6179
  window.addEventListener("mouseup", onUp);
6077
6180
  }, onContextMenu: (e) => {
6078
6181
  const target = e.target;
6182
+ const contextItems = pluginRuntime.contextMenu.filter((item) => {
6183
+ try {
6184
+ return isPluginContributionVisible(item) && (!item.when || item.when({
6185
+ root: e.currentTarget,
6186
+ target,
6187
+ selection: window.getSelection(),
6188
+ }));
6189
+ }
6190
+ catch {
6191
+ return false;
6192
+ }
6193
+ });
6194
+ if (contextItems.length) {
6195
+ e.preventDefault();
6196
+ preserveEditorSelection();
6197
+ setPluginContextMenu({
6198
+ x: Math.max(8, Math.min(e.clientX, window.innerWidth - 228)),
6199
+ y: Math.max(8, Math.min(e.clientY, window.innerHeight - 48 - contextItems.length * 36)),
6200
+ items: contextItems,
6201
+ });
6202
+ setImageMenu(null);
6203
+ setTableMenu(null);
6204
+ return;
6205
+ }
6206
+ setPluginContextMenu(null);
6079
6207
  if (target && target.tagName === "IMG") {
6080
6208
  e.preventDefault();
6081
6209
  const vw = window.innerWidth;
@@ -6103,55 +6231,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
6103
6231
  setTableMenu(null);
6104
6232
  setImageMenu(null);
6105
6233
  }
6106
- } }), dragHandle && !readOnly && (_jsx("button", { type: "button", draggable: true, "data-srte-drag-handle": "true", title: "Drag block", "aria-label": "Drag block", onMouseEnter: () => {
6107
- if (dragHandleHideTimerRef.current != null) {
6108
- window.clearTimeout(dragHandleHideTimerRef.current);
6109
- dragHandleHideTimerRef.current = null;
6110
- }
6111
- }, onMouseLeave: () => {
6112
- if (!draggedBlockRef.current)
6113
- scheduleDragHandleHide();
6114
- }, onMouseDown: (e) => {
6115
- e.stopPropagation();
6116
- }, onDragStart: (e) => {
6117
- draggedBlockRef.current = dragHandle.target;
6118
- e.dataTransfer.effectAllowed = "move";
6119
- e.dataTransfer.setData("text/plain", "moving-block");
6120
- try {
6121
- const ghost = dragHandle.target.cloneNode(true);
6122
- ghost.style.position = "fixed";
6123
- ghost.style.left = "-10000px";
6124
- ghost.style.top = "-10000px";
6125
- ghost.style.width = `${Math.min(dragHandle.target.getBoundingClientRect().width, 480)}px`;
6126
- ghost.style.opacity = "0.75";
6127
- document.body.appendChild(ghost);
6128
- e.dataTransfer.setDragImage(ghost, 12, 12);
6129
- window.setTimeout(() => ghost.remove(), 0);
6130
- }
6131
- catch { }
6132
- }, onDragEnd: () => {
6133
- draggedBlockRef.current = null;
6134
- updateDragHandleForTarget(dragHandle.target);
6135
- }, style: {
6136
- position: "absolute",
6137
- left: dragHandle.left,
6138
- top: dragHandle.top + Math.max(0, (dragHandle.height - 24) / 2),
6139
- width: 24,
6140
- height: 24,
6141
- display: "inline-flex",
6142
- alignItems: "center",
6143
- justifyContent: "center",
6144
- border: "1px solid var(--srte-border)",
6145
- borderRadius: 6,
6146
- background: "var(--srte-input-bg)",
6147
- color: "var(--srte-input-text)",
6148
- boxShadow: "var(--srte-menu-shadow)",
6149
- cursor: "grab",
6150
- zIndex: 8,
6151
- fontSize: 14,
6152
- lineHeight: 1,
6153
- padding: 0,
6154
- }, children: "\u22EE\u22EE" })), selectedImage && imageOverlay && (_jsxs("div", { style: {
6234
+ } }), selectedImage && imageOverlay && (_jsxs("div", { style: {
6155
6235
  position: "absolute",
6156
6236
  left: imageOverlay.left,
6157
6237
  top: imageOverlay.top,
@@ -6187,6 +6267,11 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
6187
6267
  window.removeEventListener("mousemove", onMove);
6188
6268
  window.removeEventListener("mouseup", onUp);
6189
6269
  resizingRef.current = null;
6270
+ if (editableRef.current && selectedImage) {
6271
+ editorControllerRef.current.bindRoot(editableRef.current).updateInlineImage(selectedImage, {
6272
+ width: Math.max(1, Math.round(selectedImage.getBoundingClientRect().width)),
6273
+ });
6274
+ }
6190
6275
  handleInput();
6191
6276
  };
6192
6277
  window.addEventListener("mousemove", onMove);
@@ -6225,6 +6310,11 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
6225
6310
  window.removeEventListener("mousemove", onMove);
6226
6311
  window.removeEventListener("mouseup", onUp);
6227
6312
  resizingRef.current = null;
6313
+ if (editableRef.current && selectedImage) {
6314
+ editorControllerRef.current.bindRoot(editableRef.current).updateInlineImage(selectedImage, {
6315
+ width: Math.max(1, Math.round(selectedImage.getBoundingClientRect().width)),
6316
+ });
6317
+ }
6228
6318
  handleInput();
6229
6319
  };
6230
6320
  window.addEventListener("mousemove", onMove);
@@ -6240,7 +6330,24 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
6240
6330
  borderRadius: 2,
6241
6331
  cursor: "ew-resize",
6242
6332
  pointerEvents: "auto",
6243
- } })] }))] }), linkMenu && (_jsx(LinkEditorPopover, { x: linkMenu.x, y: linkMenu.y, initialHref: linkMenu.initialHref, initialText: linkMenu.initialText, initialOpenInNewTab: linkMenu.anchor?.target === "_blank", showTextInput: linkMenu.showTextInput, showOpen: Boolean(linkMenu.anchor), showRemove: Boolean(linkMenu.anchor), onApply: applyLinkEditorValue, onOpen: linkMenu.anchor ? () => {
6333
+ } })] }))] }), pluginContextMenu && (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 70 }, onMouseDown: () => setPluginContextMenu(null), onContextMenu: (event) => {
6334
+ event.preventDefault();
6335
+ setPluginContextMenu(null);
6336
+ }, children: _jsx("div", { role: "menu", "aria-label": "Plugin actions", style: {
6337
+ position: "fixed",
6338
+ left: pluginContextMenu.x,
6339
+ top: pluginContextMenu.y,
6340
+ minWidth: 200,
6341
+ padding: 6,
6342
+ border: "1px solid var(--srte-border)",
6343
+ borderRadius: 8,
6344
+ background: "var(--srte-input-bg)",
6345
+ boxShadow: "var(--srte-menu-shadow)",
6346
+ }, onMouseDown: (event) => event.stopPropagation(), children: pluginContextMenu.items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: "srte-menu-item", disabled: !isPluginContributionEnabled(item), onClick: () => {
6347
+ restoreSavedSelection();
6348
+ executePluginCommand(item.commandId, item.input);
6349
+ setPluginContextMenu(null);
6350
+ }, children: _jsx("span", { children: item.label }) }, item.id))) }) })), linkMenu && (_jsx(LinkEditorPopover, { x: linkMenu.x, y: linkMenu.y, initialHref: linkMenu.initialHref, initialText: linkMenu.initialText, initialOpenInNewTab: linkMenu.anchor?.target === "_blank", showTextInput: linkMenu.showTextInput, showOpen: Boolean(linkMenu.anchor), showRemove: Boolean(linkMenu.anchor), onApply: applyLinkEditorValue, onOpen: linkMenu.anchor ? () => {
6244
6351
  openEditorLink(linkMenu.anchor);
6245
6352
  setLinkMenu(null);
6246
6353
  } : undefined, onRemove: linkMenu.anchor ? () => removeAnchorLink(linkMenu.anchor) : undefined, onCancel: () => setLinkMenu(null) })), tableMenu && (_jsx("div", { style: {
@@ -6608,7 +6715,12 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
6608
6715
  scheduleImageOverlay();
6609
6716
  handleInput();
6610
6717
  }, children: "Reset" })] }), _jsxs("div", { style: { display: "flex", gap: 6 }, children: [_jsx("button", { style: { color: "var(--srte-danger)" }, onClick: () => {
6611
- imageMenu.img.remove();
6718
+ pushEditorHistory();
6719
+ const removedThroughCore = editableRef.current
6720
+ ? editorControllerRef.current.bindRoot(editableRef.current).deleteInlineAtom(imageMenu.img)
6721
+ : false;
6722
+ if (!removedThroughCore)
6723
+ imageMenu.img.remove();
6612
6724
  setImageMenu(null);
6613
6725
  setSelectedImage(null);
6614
6726
  setImageOverlay(null);