smartrte-react 0.2.10 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { MediaManager } from "./MediaManager.js";
4
+ import { LinkEditorPopover } from "./LinkEditorPopover.js";
4
5
  import * as pdfjsLib from 'pdfjs-dist';
5
6
  import mammoth from 'mammoth';
6
7
  import JSZip from 'jszip';
8
+ import { applyLink, applyTextColor as coreApplyTextColor, markdownToCompatibilityHtml, removeLink, sanitizeLinkHref, toggleBold, toggleItalic, toggleSubscript, toggleSuperscript, toggleUnderline } from 'smartrte-core';
9
+ import { restoreSelectionToDom, selectionFromDom } from '../adapters/domSelectionBridge.js';
10
+ import { serializeSmartDocument, smartDocumentFromEditorRoot } from '../adapters/domSmartDocument.js';
11
+ import { isShadowModeEnabled, runShadowCommand } from '../adapters/shadowMode.js';
12
+ import { getCoreInlineMarkResult, isCoreInlineMarkEnabled } from '../adapters/inlineMarkCoreExecution.js';
13
+ import { closestFromTarget, isNode } from '../adapters/domTargets.js';
7
14
  import { ensureStyleSheet } from '../theme.js';
8
15
  // Initialize PDF.js worker
9
16
  if (typeof window !== 'undefined') {
@@ -50,11 +57,13 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
50
57
  const selectionRef = useRef(null);
51
58
  const selectingRef = useRef(null);
52
59
  const [imageMenu, setImageMenu] = useState(null);
60
+ const [linkMenu, setLinkMenu] = useState(null);
53
61
  const [showMediaManager, setShowMediaManager] = useState(false);
54
62
  const [showColorPicker, setShowColorPicker] = useState(false);
55
63
  const [showSpecialChars, setShowSpecialChars] = useState(false);
56
64
  const [colorPickerType, setColorPickerType] = useState('text');
57
65
  const savedRangeRef = useRef(null);
66
+ const pendingFontSizeRef = useRef(null);
58
67
  const inlineScriptCaretOverrideRef = useRef(null);
59
68
  const historyRef = useRef({
60
69
  undo: [],
@@ -64,6 +73,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
64
73
  const [currentFontSize, setCurrentFontSize] = useState("");
65
74
  const [currentFont, setCurrentFont] = useState("");
66
75
  const [currentBlockType, setCurrentBlockType] = useState("p");
76
+ const [currentAlignment, setCurrentAlignment] = useState("left");
67
77
  const [activeState, setActiveState] = useState({
68
78
  bold: false,
69
79
  italic: false,
@@ -71,10 +81,12 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
71
81
  strikeThrough: false,
72
82
  subscript: false,
73
83
  superscript: false,
84
+ checklist: false,
74
85
  unorderedList: false,
75
86
  orderedList: false,
76
87
  blockquote: false,
77
88
  codeBlock: false,
89
+ link: false,
78
90
  });
79
91
  useEffect(() => {
80
92
  const el = editableRef.current;
@@ -84,9 +96,17 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
84
96
  if (typeof value === "string" && value !== el.innerHTML) {
85
97
  el.innerHTML = value || "";
86
98
  fixNegativeMargins(el);
99
+ normalizeInvalidQuoteNesting(el);
100
+ normalizeInvalidCodeBlockNesting(el);
101
+ normalizeInvalidTableNesting(el);
87
102
  ensureTableWrappers(el);
103
+ ensureCaretBoundaryParagraphs(el);
88
104
  addTableResizeHandles();
89
105
  }
106
+ normalizeInvalidQuoteNesting(el);
107
+ normalizeInvalidCodeBlockNesting(el);
108
+ normalizeInvalidTableNesting(el);
109
+ ensureCaretBoundaryParagraphs(el);
90
110
  // Suppress native context menu inside table cells at capture phase
91
111
  const onCtx = (evt) => {
92
112
  const target = evt.target;
@@ -100,6 +120,83 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
100
120
  el.removeEventListener("contextmenu", onCtx, { capture: true });
101
121
  };
102
122
  }, [value]);
123
+ const parseFontSizePx = (value) => {
124
+ const match = /^([\d.]+)(px|pt)?$/i.exec(value.trim());
125
+ if (!match)
126
+ return null;
127
+ const numeric = Number(match[1]);
128
+ if (!Number.isFinite(numeric) || numeric <= 0)
129
+ return null;
130
+ return match[2]?.toLowerCase() === "pt" ? numeric * 4 / 3 : numeric;
131
+ };
132
+ const explicitFontSizeAt = (node) => {
133
+ let element = node instanceof HTMLElement ? node : node.parentElement;
134
+ while (element && element !== editableRef.current) {
135
+ const parsed = parseFontSizePx(element.style.fontSize);
136
+ if (parsed)
137
+ return parsed;
138
+ element = element.parentElement;
139
+ }
140
+ return null;
141
+ };
142
+ const normalizeFontSizeSpans = (root) => {
143
+ Array.from(root.querySelectorAll('span[style*="font-size"]')).reverse().forEach((span) => {
144
+ const parent = span.parentElement;
145
+ if (parent?.tagName === "SPAN" &&
146
+ parseFontSizePx(parent.style.fontSize) === parseFontSizePx(span.style.fontSize) &&
147
+ span.attributes.length === 1) {
148
+ while (span.firstChild)
149
+ parent.insertBefore(span.firstChild, span);
150
+ span.remove();
151
+ }
152
+ });
153
+ Array.from(root.querySelectorAll('span[style*="font-size"]')).forEach((span) => {
154
+ let next = span.nextElementSibling;
155
+ while (next?.tagName === "SPAN" &&
156
+ next.getAttribute("style") === span.getAttribute("style") &&
157
+ next.attributes.length === span.attributes.length) {
158
+ while (next.firstChild)
159
+ span.appendChild(next.firstChild);
160
+ const following = next.nextElementSibling;
161
+ next.remove();
162
+ next = following;
163
+ }
164
+ });
165
+ };
166
+ const resolveFontSizeForRange = (range) => {
167
+ const pending = pendingFontSizeRef.current;
168
+ if (range.collapsed && pending &&
169
+ range.startContainer === pending.container &&
170
+ range.startOffset === pending.offset)
171
+ return String(Math.round(pending.valuePx));
172
+ if (range.collapsed) {
173
+ const explicit = explicitFontSizeAt(range.startContainer);
174
+ return explicit ? String(Math.round(explicit)) : "";
175
+ }
176
+ const editor = editableRef.current;
177
+ if (!editor)
178
+ return "";
179
+ const sizes = new Set();
180
+ const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
181
+ let node = walker.nextNode();
182
+ while (node) {
183
+ try {
184
+ if (range.intersectsNode(node) && node.textContent) {
185
+ const size = explicitFontSizeAt(node);
186
+ if (size)
187
+ sizes.add(Math.round(size));
188
+ else
189
+ sizes.add(0);
190
+ }
191
+ }
192
+ catch { }
193
+ if (sizes.size > 1)
194
+ return "";
195
+ node = walker.nextNode();
196
+ }
197
+ const only = Array.from(sizes)[0];
198
+ return only ? String(only) : "";
199
+ };
103
200
  const updateActiveState = () => {
104
201
  const editor = editableRef.current;
105
202
  if (!editor)
@@ -116,6 +213,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
116
213
  const element = node instanceof HTMLElement ? node : null;
117
214
  const block = element?.closest("p,h1,h2,h3,h4,h5,h6,li,blockquote,pre,div");
118
215
  const tag = block?.tagName.toLowerCase();
216
+ const queryState = (command) => typeof document.queryCommandState === "function" && document.queryCommandState(command);
119
217
  const scriptOverride = inlineScriptCaretOverrideRef.current;
120
218
  const overrideApplies = Boolean(scriptOverride &&
121
219
  range.collapsed &&
@@ -124,24 +222,50 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
124
222
  if (scriptOverride && !overrideApplies) {
125
223
  inlineScriptCaretOverrideRef.current = null;
126
224
  }
127
- const subscriptActive = document.queryCommandState("subscript") || Boolean(element?.closest("sub"));
128
- const superscriptActive = document.queryCommandState("superscript") || Boolean(element?.closest("sup"));
129
- setCurrentBlockType(tag === "h1" || tag === "h2" || tag === "h3" ? tag : "p");
225
+ const subscriptActive = queryState("subscript") || Boolean(element?.closest("sub"));
226
+ const superscriptActive = queryState("superscript") || Boolean(element?.closest("sup"));
227
+ if (!range.collapsed) {
228
+ const formattingBlocks = getSelectedBlocks(range);
229
+ [range.startContainer, range.endContainer].forEach((endpoint) => {
230
+ const endpointElement = endpoint instanceof HTMLElement ? endpoint : endpoint.parentElement;
231
+ const endpointBlock = endpointElement?.closest("p,h1,h2,h3,h4,h5,h6,li");
232
+ if (endpointBlock && editor.contains(endpointBlock) && !formattingBlocks.includes(endpointBlock)) {
233
+ formattingBlocks.push(endpointBlock);
234
+ }
235
+ });
236
+ const types = new Set(formattingBlocks.map((selectedBlock) => {
237
+ const contentBlock = selectedBlock.tagName === "LI"
238
+ ? selectedBlock.querySelector(":scope > p,:scope > h1,:scope > h2,:scope > h3,:scope > h4,:scope > h5,:scope > h6")
239
+ : selectedBlock;
240
+ const selectedTag = contentBlock?.tagName.toLowerCase() || "p";
241
+ return /^h[1-6]$/.test(selectedTag) ? selectedTag : "p";
242
+ }));
243
+ setCurrentBlockType(types.size > 1 ? "mixed" : (Array.from(types)[0] || "p"));
244
+ }
245
+ else {
246
+ setCurrentBlockType(/^h[1-6]$/.test(tag || "") ? tag : "p");
247
+ }
248
+ setCurrentFontSize(resolveFontSizeForRange(range));
249
+ const alignmentTargets = getAlignmentTargets(range);
250
+ const alignments = new Set(alignmentTargets.map(readTextAlignment));
251
+ setCurrentAlignment(alignments.size > 1 ? "mixed" : Array.from(alignments)[0] || "left");
130
252
  setActiveState({
131
- bold: document.queryCommandState("bold"),
132
- italic: document.queryCommandState("italic"),
133
- underline: document.queryCommandState("underline"),
134
- strikeThrough: document.queryCommandState("strikeThrough"),
253
+ bold: queryState("bold"),
254
+ italic: queryState("italic"),
255
+ underline: queryState("underline"),
256
+ strikeThrough: queryState("strikeThrough"),
135
257
  subscript: overrideApplies && scriptOverride?.command === "subscript"
136
258
  ? false
137
259
  : subscriptActive,
138
260
  superscript: overrideApplies && scriptOverride?.command === "superscript"
139
261
  ? false
140
262
  : superscriptActive,
141
- unorderedList: Boolean(element?.closest("ul")),
263
+ checklist: Boolean(element?.closest('[data-srte-checklist="true"]')),
264
+ unorderedList: Boolean(element?.closest("ul:not([data-srte-checklist=\"true\"])")),
142
265
  orderedList: Boolean(element?.closest("ol")),
143
266
  blockquote: Boolean(element?.closest("blockquote")),
144
267
  codeBlock: Boolean(element?.closest("pre")),
268
+ link: Boolean(element?.closest("a")),
145
269
  });
146
270
  }
147
271
  catch { }
@@ -154,6 +278,10 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
154
278
  const range = sel.getRangeAt(0);
155
279
  const editor = editableRef.current;
156
280
  if (editor && editor.contains(range.commonAncestorContainer)) {
281
+ const pending = pendingFontSizeRef.current;
282
+ if (pending &&
283
+ (!range.collapsed || range.startContainer !== pending.container || range.startOffset !== pending.offset))
284
+ pendingFontSizeRef.current = null;
157
285
  savedRangeRef.current = range.cloneRange();
158
286
  updateActiveState();
159
287
  }
@@ -182,6 +310,52 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
182
310
  return;
183
311
  }
184
312
  pushEditorHistory();
313
+ const editor = editableRef.current;
314
+ const coreMark = { bold: "bold", italic: "italic", underline: "underline", superscript: "superscript", subscript: "subscript" }[command];
315
+ if (coreMark && editor && isCoreInlineMarkEnabled(coreMark)) {
316
+ try {
317
+ const result = getCoreInlineMarkResult(editor, coreMark);
318
+ if (result) {
319
+ editor.innerHTML = result.html;
320
+ ensureTableWrappers(editor);
321
+ addTableResizeHandles();
322
+ if (!restoreSelectionToDom(editor, result.selectionAfter)) {
323
+ const fallback = document.createRange();
324
+ fallback.selectNodeContents(editor);
325
+ fallback.collapse(false);
326
+ safeSelectRange(fallback);
327
+ if (isShadowModeEnabled())
328
+ console.warn(`[Smart RTE] Core ${coreMark} could not restore its exact selection.`);
329
+ }
330
+ handleInput();
331
+ return;
332
+ }
333
+ }
334
+ catch (error) {
335
+ if (isShadowModeEnabled())
336
+ console.warn(`[Smart RTE] Core ${coreMark} fell back to legacy execution.`, error);
337
+ }
338
+ }
339
+ const shadowCommand = {
340
+ bold: toggleBold,
341
+ italic: toggleItalic,
342
+ underline: toggleUnderline,
343
+ superscript: toggleSuperscript,
344
+ subscript: toggleSubscript,
345
+ foreColor: coreApplyTextColor,
346
+ createLink: applyLink,
347
+ unlink: removeLink,
348
+ }[command];
349
+ const shadowInput = command === "createLink" ? { href: valueArg || "" } : valueArg;
350
+ const shadowState = shadowCommand && editor && isShadowModeEnabled()
351
+ ? (() => {
352
+ const selection = selectionFromDom(editor, window.getSelection());
353
+ if (!selection)
354
+ return null;
355
+ const { document: coreDocument } = smartDocumentFromEditorRoot(editor);
356
+ return { document: coreDocument, selection };
357
+ })()
358
+ : null;
185
359
  const beforeHtml = editableRef.current?.innerHTML || "";
186
360
  const ok = document.execCommand(command, false, valueArg);
187
361
  const afterHtml = editableRef.current?.innerHTML || "";
@@ -191,6 +365,16 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
191
365
  if (command === "formatBlock" && valueArg)
192
366
  applyFormatBlockFallback(valueArg);
193
367
  }
368
+ if (shadowCommand && shadowState) {
369
+ runShadowCommand({
370
+ command: shadowCommand,
371
+ context: { document: shadowState.document, selection: shadowState.selection },
372
+ input: shadowInput,
373
+ state: shadowState,
374
+ legacyHtml: afterHtml,
375
+ serialize: (state) => serializeSmartDocument(state.document),
376
+ });
377
+ }
194
378
  handleInput();
195
379
  }
196
380
  catch { }
@@ -268,16 +452,16 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
268
452
  if (!restoreSavedSelection()) {
269
453
  safeSelectRange(getSelectionRangeInEditor());
270
454
  }
455
+ pushEditorHistory();
271
456
  if (applyFormatBlockFallback(blockName)) {
272
457
  const tag = normalizeBlockTag(blockName);
273
- if (tag === "p" || tag === "h1" || tag === "h2" || tag === "h3") {
458
+ if (tag === "p" || /^h[1-6]$/.test(tag || "")) {
274
459
  setCurrentBlockType(tag);
275
460
  }
276
461
  handleInput();
277
462
  requestAnimationFrame(updateActiveState);
278
463
  return;
279
464
  }
280
- exec("formatBlock", blockName);
281
465
  }
282
466
  catch { }
283
467
  };
@@ -349,7 +533,9 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
349
533
  to.push(currentHtml);
350
534
  editor.innerHTML = html;
351
535
  fixNegativeMargins(editor);
536
+ normalizeInvalidCodeBlockNesting(editor);
352
537
  ensureTableWrappers(editor);
538
+ ensureCaretBoundaryParagraphs(editor);
353
539
  addTableResizeHandles();
354
540
  clearSelectionDecor();
355
541
  setTableMenu(null);
@@ -398,18 +584,111 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
398
584
  savedRangeRef.current = range.cloneRange();
399
585
  }
400
586
  };
401
- const insertLink = () => {
402
- const url = window.prompt("Enter URL", "https://");
403
- if (!url)
587
+ const escapeAttribute = (value) => value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
588
+ const getSelectedAnchor = () => {
589
+ const editor = editableRef.current;
590
+ const range = getSelectionRangeInEditor();
591
+ if (!editor || !range)
592
+ return null;
593
+ const node = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement : range.startContainer;
594
+ const anchor = node?.closest?.("a");
595
+ return anchor && editor.contains(anchor) ? anchor : null;
596
+ };
597
+ const selectAnchorContents = (anchor) => {
598
+ const range = document.createRange();
599
+ range.selectNodeContents(anchor);
600
+ safeSelectRange(range);
601
+ savedRangeRef.current = range.cloneRange();
602
+ };
603
+ const getRangeRect = (range) => {
604
+ if (!range || typeof range.getBoundingClientRect !== "function")
605
+ return null;
606
+ const rect = range.getBoundingClientRect();
607
+ return rect.width || rect.height ? rect : null;
608
+ };
609
+ const openLinkEditor = (existingAnchor) => {
610
+ const editor = editableRef.current;
611
+ if (!editor)
612
+ return;
613
+ const anchor = existingAnchor || getSelectedAnchor();
614
+ const range = anchor
615
+ ? (() => {
616
+ const anchorRange = document.createRange();
617
+ anchorRange.selectNodeContents(anchor);
618
+ return anchorRange;
619
+ })()
620
+ : getSelectionRangeInEditor();
621
+ const rect = anchor?.getBoundingClientRect() || getRangeRect(range) || editor.getBoundingClientRect();
622
+ setLinkMenu({
623
+ x: Math.max(8, Math.min(rect.left, window.innerWidth - 320)),
624
+ y: Math.max(8, Math.min(rect.bottom + 8, window.innerHeight - 180)),
625
+ anchor: anchor || undefined,
626
+ range: range ? range.cloneRange() : null,
627
+ initialHref: anchor?.getAttribute("href") || "",
628
+ initialText: anchor?.textContent || (range && !range.collapsed ? range.toString() : ""),
629
+ showTextInput: Boolean(anchor || !range || range.collapsed),
630
+ });
631
+ };
632
+ const applyLinkEditorValue = (value) => {
633
+ const state = linkMenu;
634
+ if (!state)
635
+ return;
636
+ setLinkMenu(null);
637
+ if (state.anchor) {
638
+ pushEditorHistory();
639
+ state.anchor.setAttribute("href", value.href);
640
+ updateAnchorTarget(state.anchor, value.openInNewTab);
641
+ if (value.text != null && value.text !== state.anchor.textContent) {
642
+ state.anchor.textContent = value.text;
643
+ }
644
+ selectAnchorContents(state.anchor);
645
+ handleInput();
646
+ return;
647
+ }
648
+ if (state.range)
649
+ safeSelectRange(state.range.cloneRange());
650
+ if (state.range && !state.range.collapsed) {
651
+ exec("createLink", value.href);
652
+ const createdAnchor = getSelectedAnchor();
653
+ if (createdAnchor) {
654
+ updateAnchorTarget(createdAnchor, value.openInNewTab);
655
+ handleInput();
656
+ }
657
+ return;
658
+ }
659
+ if (!value.text)
660
+ return;
661
+ pushEditorHistory();
662
+ const targetAttributes = value.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : "";
663
+ document.execCommand("insertHTML", false, `<a href="${escapeAttribute(value.href)}"${targetAttributes}>${escapeAttribute(value.text)}</a>`);
664
+ handleInput();
665
+ };
666
+ const updateAnchorTarget = (anchor, openInNewTab) => {
667
+ const otherRelValues = (anchor.getAttribute("rel") || "")
668
+ .split(/\s+/)
669
+ .filter((value) => value && value !== "noopener" && value !== "noreferrer");
670
+ if (openInNewTab) {
671
+ anchor.target = "_blank";
672
+ anchor.rel = [...otherRelValues, "noopener", "noreferrer"].join(" ");
404
673
  return;
405
- exec("createLink", url);
674
+ }
675
+ anchor.removeAttribute("target");
676
+ if (otherRelValues.length)
677
+ anchor.rel = otherRelValues.join(" ");
678
+ else
679
+ anchor.removeAttribute("rel");
680
+ };
681
+ const removeAnchorLink = (anchor) => {
682
+ selectAnchorContents(anchor);
683
+ exec("unlink");
684
+ setLinkMenu(null);
406
685
  };
407
686
  const openEditorLink = (anchor) => {
408
- const rawHref = anchor.getAttribute("href")?.trim();
409
- if (!rawHref)
687
+ const safeHref = sanitizeLinkHref(anchor.getAttribute("href"));
688
+ if (!safeHref)
410
689
  return;
411
690
  try {
412
- const url = new URL(rawHref, window.location.href);
691
+ const url = new URL(safeHref, window.location.href);
413
692
  if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol))
414
693
  return;
415
694
  const target = anchor.getAttribute("target") || "_blank";
@@ -442,12 +721,22 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
442
721
  const range = getSelectionRangeInEditor();
443
722
  if (!editor || !range)
444
723
  return null;
445
- let node = range.commonAncestorContainer;
724
+ let node = range.startContainer;
446
725
  if (node.nodeType === Node.TEXT_NODE)
447
726
  node = node.parentNode;
448
727
  const element = node instanceof HTMLElement ? node : null;
728
+ const cell = element?.closest("td,th");
729
+ if (cell && editor.contains(cell)) {
730
+ const cellBlock = element?.closest("p,h1,h2,h3,h4,h5,h6,blockquote,pre");
731
+ if (cellBlock && cell.contains(cellBlock))
732
+ return cellBlock;
733
+ const directBlock = Array.from(cell.children).find((child) => child.matches("p,h1,h2,h3,h4,h5,h6,blockquote,pre"));
734
+ if (directBlock instanceof HTMLElement)
735
+ return directBlock;
736
+ return cell;
737
+ }
449
738
  const block = element?.closest("p,h1,h2,h3,h4,h5,h6,li,blockquote,pre,div");
450
- if (!block || block === editor || !editor.contains(block))
739
+ if (!block || block === editor || block.getAttribute("data-table-wrapper") === "true" || !editor.contains(block))
451
740
  return null;
452
741
  return block;
453
742
  };
@@ -456,22 +745,43 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
456
745
  const editor = editableRef.current;
457
746
  if (!editor)
458
747
  return [];
459
- const blocks = Array.from(editor.querySelectorAll(blockSelector))
460
- .filter((block) => {
461
- if (block === editor)
748
+ if (range.collapsed) {
749
+ const current = getCurrentBlock();
750
+ return current ? [current] : [];
751
+ }
752
+ const startElement = range.startContainer.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement : range.startContainer;
753
+ const endElement = range.endContainer.nodeType === Node.TEXT_NODE ? range.endContainer.parentElement : range.endContainer;
754
+ const startCell = startElement?.closest?.("td,th");
755
+ const endCell = endElement?.closest?.("td,th");
756
+ const scope = startCell && startCell === endCell && editor.contains(startCell) ? startCell : editor;
757
+ const blocks = Array.from(scope.querySelectorAll(blockSelector)).filter((block) => {
758
+ if (block === editor || block.getAttribute("data-table-wrapper") === "true")
462
759
  return false;
463
760
  const parentBlock = block.parentElement?.closest(blockSelector);
464
- if (parentBlock && parentBlock !== editor && editor.contains(parentBlock))
761
+ if (parentBlock && scope.contains(parentBlock) && parentBlock !== scope)
465
762
  return false;
466
763
  try {
467
- return range.intersectsNode(block);
764
+ if (!range.intersectsNode(block))
765
+ return false;
766
+ const parent = block.parentNode;
767
+ if (parent === range.endContainer) {
768
+ const index = Array.prototype.indexOf.call(parent.childNodes, block);
769
+ if (range.endOffset <= index)
770
+ return false;
771
+ }
772
+ if (parent === range.startContainer) {
773
+ const index = Array.prototype.indexOf.call(parent.childNodes, block);
774
+ if (range.startOffset > index)
775
+ return false;
776
+ }
777
+ return true;
468
778
  }
469
779
  catch {
470
780
  return false;
471
781
  }
472
782
  });
473
783
  if (blocks.length > 0)
474
- return blocks;
784
+ return sortInDocumentOrder(blocks);
475
785
  const current = getCurrentBlock();
476
786
  return current ? [current] : [];
477
787
  };
@@ -489,6 +799,67 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
489
799
  });
490
800
  return items;
491
801
  };
802
+ const getAlignmentTarget = (block) => {
803
+ const item = block.tagName === "LI" ? block : block.closest("li");
804
+ return item instanceof HTMLElement ? item : block;
805
+ };
806
+ const getAlignmentTargets = (range) => {
807
+ const editor = editableRef.current;
808
+ if (!editor)
809
+ return [];
810
+ const tableSelection = selectionRef.current;
811
+ const rangeElement = range.startContainer instanceof HTMLElement ? range.startContainer : range.startContainer.parentElement;
812
+ const rangeCell = rangeElement?.closest("td,th");
813
+ const selectedCells = tableSelection && rangeCell && isCellInsideSelection(rangeCell)
814
+ ? getCellsInGridRect(tableSelection.tbody, tableSelection.sr, tableSelection.sc, tableSelection.er, tableSelection.ec)
815
+ : [];
816
+ const candidates = selectedCells.length > 0
817
+ ? selectedCells.flatMap((cell) => {
818
+ const blocks = Array.from(cell.children).filter((child) => child instanceof HTMLElement && child.matches("p,h1,h2,h3,h4,h5,h6,blockquote,pre"));
819
+ return blocks.length > 0 ? blocks : [cell];
820
+ })
821
+ : getSelectedBlocks(range).map(getAlignmentTarget);
822
+ const seen = new Set();
823
+ return candidates.filter((target) => {
824
+ if (!editor.contains(target) || seen.has(target))
825
+ return false;
826
+ seen.add(target);
827
+ return true;
828
+ });
829
+ };
830
+ const readTextAlignment = (target) => {
831
+ const explicit = target.style.textAlign;
832
+ if (explicit === "center" || explicit === "right" || explicit === "justify")
833
+ return explicit;
834
+ const inherited = target.closest("blockquote[style*='text-align']");
835
+ const inheritedValue = inherited?.style.textAlign;
836
+ return inheritedValue === "center" || inheritedValue === "right" || inheritedValue === "justify"
837
+ ? inheritedValue
838
+ : "left";
839
+ };
840
+ const applyTextAlignment = (alignment) => {
841
+ try {
842
+ if (!restoreSavedSelection())
843
+ safeSelectRange(getSelectionRangeInEditor());
844
+ const range = getSelectionRangeInEditor();
845
+ if (!range)
846
+ return;
847
+ const targets = getAlignmentTargets(range);
848
+ if (targets.length === 0)
849
+ return;
850
+ pushEditorHistory();
851
+ targets.forEach((target) => {
852
+ target.style.textAlign = alignment === "left" ? "" : alignment;
853
+ if (!target.getAttribute("style"))
854
+ target.removeAttribute("style");
855
+ });
856
+ safeSelectRange(range);
857
+ savedRangeRef.current = range.cloneRange();
858
+ handleInput();
859
+ requestAnimationFrame(updateActiveState);
860
+ }
861
+ catch { }
862
+ };
492
863
  const copyCellOrBlockStyles = (from, to) => {
493
864
  to.innerHTML = from.innerHTML || "<br>";
494
865
  const style = from.getAttribute("style");
@@ -555,6 +926,39 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
555
926
  block.parentElement?.replaceChild(replacement, block);
556
927
  return replacement;
557
928
  };
929
+ const clearExplicitFontSizes = (block) => {
930
+ Array.from(block.querySelectorAll('[style*="font-size"]')).forEach((element) => {
931
+ element.style.fontSize = "";
932
+ if (!element.style.cssText)
933
+ element.removeAttribute("style");
934
+ if (element.tagName === "SPAN" && !element.getAttribute("style") && element.attributes.length === 0) {
935
+ const parent = element.parentNode;
936
+ if (!parent)
937
+ return;
938
+ while (element.firstChild)
939
+ parent.insertBefore(element.firstChild, element);
940
+ element.remove();
941
+ }
942
+ });
943
+ };
944
+ const replaceListItemContentTag = (item, tag) => {
945
+ const directBlock = Array.from(item.children).find((child) => child.matches("p,h1,h2,h3,h4,h5,h6"));
946
+ if (directBlock)
947
+ return replaceBlockTag(directBlock, tag);
948
+ const block = document.createElement(tag);
949
+ const boundary = Array.from(item.children).find((child) => child.matches("ul,ol,blockquote,pre")) || null;
950
+ Array.from(item.childNodes).forEach((node) => {
951
+ if (node === boundary)
952
+ return;
953
+ if (node instanceof HTMLElement && node.dataset.srteCheck === "true")
954
+ return;
955
+ block.appendChild(node);
956
+ });
957
+ if (!block.childNodes.length)
958
+ block.innerHTML = "<br>";
959
+ item.insertBefore(block, boundary);
960
+ return block;
961
+ };
558
962
  const applyFormatBlockFallback = (blockName) => {
559
963
  const editor = editableRef.current;
560
964
  const range = getSelectionRangeInEditor();
@@ -570,23 +974,36 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
570
974
  }
571
975
  if (range.collapsed) {
572
976
  const block = getCurrentBlock();
573
- if (!block || block === editor || block.closest("ul,ol") || !block.parentElement)
977
+ if (!block || block === editor || !block.parentElement)
574
978
  return false;
575
- const replacement = replaceBlockTag(block, tag);
979
+ const item = block.closest("li");
980
+ const replacement = item ? replaceListItemContentTag(item, tag) : replaceBlockTag(block, tag);
981
+ if (/^h[1-6]$/.test(tag))
982
+ clearExplicitFontSizes(replacement);
576
983
  focusElementEnd(replacement);
577
984
  return true;
578
985
  }
579
986
  const selectedBlocks = sortInDocumentOrder(getSelectedBlocks(range)).filter((block) => {
580
987
  if (!editor.contains(block) || block === editor)
581
988
  return false;
582
- if (block.closest("ul,ol"))
583
- return false;
584
989
  return Boolean(block.parentElement);
585
990
  });
586
991
  if (selectedBlocks.length > 0) {
587
992
  let lastReplacement = null;
993
+ const handledItems = new Set();
588
994
  selectedBlocks.forEach((block) => {
589
- lastReplacement = replaceBlockTag(block, tag);
995
+ const item = block.closest("li");
996
+ if (item) {
997
+ if (handledItems.has(item))
998
+ return;
999
+ handledItems.add(item);
1000
+ lastReplacement = replaceListItemContentTag(item, tag);
1001
+ }
1002
+ else {
1003
+ lastReplacement = replaceBlockTag(block, tag);
1004
+ }
1005
+ if (lastReplacement && /^h[1-6]$/.test(tag))
1006
+ clearExplicitFontSizes(lastReplacement);
590
1007
  });
591
1008
  if (lastReplacement)
592
1009
  focusElementEnd(lastReplacement);
@@ -606,6 +1023,64 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
606
1023
  Array.from(list.attributes).forEach((attr) => clone.setAttribute(attr.name, attr.value));
607
1024
  return clone;
608
1025
  };
1026
+ const mergeAdjacentCompatibleLists = (list) => {
1027
+ const isCompatible = (candidate) => candidate instanceof HTMLElement &&
1028
+ candidate.tagName === list.tagName &&
1029
+ candidate.style.listStyleType === list.style.listStyleType &&
1030
+ candidate.dataset.srteChecklist === list.dataset.srteChecklist &&
1031
+ candidate.dataset.srteChecklistStrike === list.dataset.srteChecklistStrike;
1032
+ let merged = list;
1033
+ const previous = merged.previousElementSibling;
1034
+ if (isCompatible(previous)) {
1035
+ while (merged.firstChild)
1036
+ previous.appendChild(merged.firstChild);
1037
+ merged.remove();
1038
+ merged = previous;
1039
+ }
1040
+ const next = merged.nextElementSibling;
1041
+ if (isCompatible(next)) {
1042
+ while (next.firstChild)
1043
+ merged.appendChild(next.firstChild);
1044
+ next.remove();
1045
+ }
1046
+ return merged;
1047
+ };
1048
+ const clearChecklist = (list) => {
1049
+ delete list.dataset.srteChecklist;
1050
+ delete list.dataset.srteChecklistStrike;
1051
+ list.querySelectorAll(':scope > li > [data-srte-check]').forEach((control) => control.remove());
1052
+ Array.from(list.children).forEach((item) => {
1053
+ if (item instanceof HTMLElement) {
1054
+ delete item.dataset.checked;
1055
+ item.style.textDecoration = "";
1056
+ }
1057
+ });
1058
+ };
1059
+ const decorateChecklist = (list, strikeCompleted) => {
1060
+ list.dataset.srteChecklist = "true";
1061
+ list.dataset.srteChecklistStrike = strikeCompleted ? "true" : "false";
1062
+ list.style.listStyleType = "none";
1063
+ list.style.paddingInlineStart = "1.5em";
1064
+ Array.from(list.children).forEach((item) => {
1065
+ if (!(item instanceof HTMLElement) || item.tagName !== "LI")
1066
+ return;
1067
+ const legacyCheckbox = item.querySelector(':scope > input[data-srte-check]');
1068
+ const checked = item.dataset.checked === "true" || Boolean(legacyCheckbox?.checked);
1069
+ item.querySelectorAll(':scope > [data-srte-check]').forEach((control) => control.remove());
1070
+ item.dataset.checked = checked ? "true" : "false";
1071
+ const control = document.createElement("button");
1072
+ control.type = "button";
1073
+ control.dataset.srteCheck = "true";
1074
+ control.contentEditable = "false";
1075
+ control.tabIndex = -1;
1076
+ control.setAttribute("aria-label", checked ? "Mark incomplete" : "Mark complete");
1077
+ control.textContent = checked ? "☑" : "☐";
1078
+ control.style.cssText = "margin-inline:-1.45em .45em;border:0;padding:0;background:transparent;color:inherit;font:inherit;cursor:pointer";
1079
+ item.prepend(control);
1080
+ item.style.textDecoration = strikeCompleted && checked ? "line-through" : "";
1081
+ });
1082
+ return mergeAdjacentCompatibleLists(list);
1083
+ };
609
1084
  const focusElementEnd = (element) => {
610
1085
  const range = document.createRange();
611
1086
  range.selectNodeContents(element);
@@ -632,6 +1107,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
632
1107
  return;
633
1108
  const paragraph = document.createElement("p");
634
1109
  paragraph.innerHTML = li.innerHTML || "<br>";
1110
+ paragraph.style.textAlign = li.style.textAlign;
635
1111
  const beforeList = cloneListShell(list);
636
1112
  const afterList = cloneListShell(list);
637
1113
  while (list.firstChild && list.firstChild !== li) {
@@ -721,6 +1197,50 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
721
1197
  requestAnimationFrame(updateActiveState);
722
1198
  }
723
1199
  };
1200
+ const restyleSelectedListItems = (items, listTag, styleType) => {
1201
+ const editor = editableRef.current;
1202
+ if (!editor || items.length === 0)
1203
+ return null;
1204
+ const selected = new Set(items);
1205
+ const sourceLists = Array.from(new Set(items.map((item) => item.parentElement)));
1206
+ let lastSelected = null;
1207
+ sourceLists.forEach((source) => {
1208
+ const parent = source.parentElement;
1209
+ if (!parent || !editor.contains(source))
1210
+ return;
1211
+ const outputs = [];
1212
+ let pending = null;
1213
+ let pendingSelected = null;
1214
+ Array.from(source.children).forEach((child) => {
1215
+ if (!(child instanceof HTMLElement) || child.tagName !== "LI")
1216
+ return;
1217
+ const isSelected = selected.has(child);
1218
+ if (!pending || pendingSelected !== isSelected) {
1219
+ pending = isSelected ? document.createElement(listTag) : cloneListShell(source);
1220
+ if (isSelected)
1221
+ pending.style.listStyleType = styleType;
1222
+ outputs.push(pending);
1223
+ pendingSelected = isSelected;
1224
+ }
1225
+ if (isSelected) {
1226
+ child.querySelectorAll(':scope > [data-srte-check]').forEach((control) => control.remove());
1227
+ delete child.dataset.checked;
1228
+ child.style.textDecoration = "";
1229
+ lastSelected = child;
1230
+ }
1231
+ pending.appendChild(child);
1232
+ });
1233
+ outputs.forEach((output) => parent.insertBefore(output, source));
1234
+ source.remove();
1235
+ outputs.forEach((output) => {
1236
+ if (output.tagName.toLowerCase() === listTag && output.style.listStyleType === styleType) {
1237
+ clearChecklist(output);
1238
+ }
1239
+ mergeAdjacentCompatibleLists(output);
1240
+ });
1241
+ });
1242
+ return lastSelected;
1243
+ };
724
1244
  const applyListStyle = (value) => {
725
1245
  const listTag = value.startsWith("ordered:") ? "ol" : "ul";
726
1246
  const styleType = value.replace(/^(ordered|bullet):/, "");
@@ -729,6 +1249,19 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
729
1249
  const range = getSelectionRangeInEditor();
730
1250
  if (!range)
731
1251
  return;
1252
+ const selectedBlocks = getSelectedBlocks(range);
1253
+ const selectedItems = getSelectedListItems(selectedBlocks);
1254
+ const selectedPlainBlocks = selectedBlocks.filter((block) => !block.closest("ul,ol"));
1255
+ if (selectedItems.length > 0) {
1256
+ pushEditorHistory();
1257
+ const lastItem = restyleSelectedListItems(selectedItems, listTag, styleType);
1258
+ const convertedPlainBlocks = convertSelectedBlocksToList(selectedPlainBlocks, listTag, styleType);
1259
+ if (!convertedPlainBlocks && lastItem)
1260
+ focusElementEnd(lastItem);
1261
+ handleInput();
1262
+ requestAnimationFrame(updateActiveState);
1263
+ return;
1264
+ }
732
1265
  const lists = new Set();
733
1266
  getSelectedListItems(getSelectedBlocks(range)).forEach((item) => {
734
1267
  const list = item.parentElement;
@@ -744,15 +1277,40 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
744
1277
  lists.add(list);
745
1278
  });
746
1279
  if (lists.size === 0) {
747
- const block = getCurrentBlock();
748
- if (!block?.closest("ul,ol")) {
749
- toggleList(listTag);
750
- const currentList = getCurrentBlock()?.closest(listTag);
751
- if (currentList)
752
- currentList.style.listStyleType = styleType;
1280
+ const blocks = range.collapsed
1281
+ ? [getCurrentBlock()].filter((block) => Boolean(block))
1282
+ : getSelectedBlocks(range);
1283
+ const convertibleBlocks = blocks.filter((block) => editableRef.current?.contains(block) &&
1284
+ !block.closest("ul,ol") &&
1285
+ Boolean(block.parentElement));
1286
+ if (convertibleBlocks.length > 0) {
1287
+ pushEditorHistory();
1288
+ if (convertSelectedBlocksToList(convertibleBlocks, listTag)) {
1289
+ const createdList = getCurrentBlock()?.closest("ul,ol");
1290
+ if (createdList) {
1291
+ createdList.style.listStyleType = styleType;
1292
+ const mergedList = mergeAdjacentCompatibleLists(createdList);
1293
+ const lastItem = mergedList.lastElementChild;
1294
+ if (lastItem)
1295
+ focusElementEnd(lastItem);
1296
+ }
1297
+ }
753
1298
  handleInput();
1299
+ requestAnimationFrame(updateActiveState);
754
1300
  return;
755
1301
  }
1302
+ if (!range.collapsed && blocks.length === 0) {
1303
+ toggleList(listTag);
1304
+ const createdList = getCurrentBlock()?.closest("ul,ol");
1305
+ if (createdList) {
1306
+ createdList.style.listStyleType = styleType;
1307
+ handleInput();
1308
+ }
1309
+ return;
1310
+ }
1311
+ const block = getCurrentBlock();
1312
+ if (!block?.closest("ul,ol"))
1313
+ return;
756
1314
  const currentList = block.closest("ul,ol");
757
1315
  if (currentList)
758
1316
  lists.add(currentList);
@@ -761,6 +1319,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
761
1319
  return;
762
1320
  pushEditorHistory();
763
1321
  let lastList = null;
1322
+ const styledLists = [];
764
1323
  lists.forEach((list) => {
765
1324
  const target = list.tagName.toLowerCase() === listTag
766
1325
  ? list
@@ -769,8 +1328,15 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
769
1328
  target.innerHTML = list.innerHTML;
770
1329
  list.parentElement?.replaceChild(target, list);
771
1330
  }
1331
+ clearChecklist(target);
772
1332
  target.style.listStyleType = styleType;
773
1333
  lastList = target;
1334
+ styledLists.push(target);
1335
+ });
1336
+ styledLists.forEach((list) => {
1337
+ if (editableRef.current?.contains(list)) {
1338
+ lastList = mergeAdjacentCompatibleLists(list);
1339
+ }
774
1340
  });
775
1341
  const lastItem = lastList?.lastElementChild;
776
1342
  if (lastItem)
@@ -778,7 +1344,54 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
778
1344
  handleInput();
779
1345
  requestAnimationFrame(updateActiveState);
780
1346
  };
781
- const convertSelectedBlocksToList = (blocks, listTag) => {
1347
+ const applyChecklist = (strikeCompleted = false, toggleOff = false) => {
1348
+ if (!restoreSavedSelection())
1349
+ safeSelectRange(getSelectionRangeInEditor());
1350
+ const range = getSelectionRangeInEditor();
1351
+ if (!range)
1352
+ return;
1353
+ const selectedItems = getSelectedListItems(getSelectedBlocks(range));
1354
+ const selectedChecklists = selectedItems.filter((item) => item.parentElement?.dataset.srteChecklist === "true");
1355
+ if (toggleOff && selectedItems.length > 0 && selectedChecklists.length === selectedItems.length) {
1356
+ pushEditorHistory();
1357
+ selectedItems.forEach((item) => {
1358
+ item.querySelectorAll(':scope > [data-srte-check]').forEach((control) => control.remove());
1359
+ delete item.dataset.checked;
1360
+ item.style.textDecoration = "";
1361
+ });
1362
+ if (transformSelectedListItems(selectedItems, "ul")) {
1363
+ handleInput();
1364
+ requestAnimationFrame(updateActiveState);
1365
+ }
1366
+ return;
1367
+ }
1368
+ if (selectedItems.length > 0) {
1369
+ pushEditorHistory();
1370
+ const lastItem = restyleSelectedListItems(selectedItems, "ul", "none");
1371
+ const lists = new Set();
1372
+ selectedItems.forEach((item) => {
1373
+ if (item.parentElement)
1374
+ lists.add(item.parentElement);
1375
+ });
1376
+ lists.forEach((list) => decorateChecklist(list, strikeCompleted));
1377
+ if (lastItem)
1378
+ focusElementEnd(lastItem);
1379
+ handleInput();
1380
+ requestAnimationFrame(updateActiveState);
1381
+ return;
1382
+ }
1383
+ applyListStyle("bullet:none");
1384
+ const createdList = getCurrentBlock()?.closest("ul");
1385
+ if (!createdList)
1386
+ return;
1387
+ const merged = decorateChecklist(createdList, strikeCompleted);
1388
+ const lastItem = merged.lastElementChild;
1389
+ if (lastItem)
1390
+ focusElementEnd(lastItem);
1391
+ handleInput();
1392
+ requestAnimationFrame(updateActiveState);
1393
+ };
1394
+ const convertSelectedBlocksToList = (blocks, listTag, styleType) => {
782
1395
  const editor = editableRef.current;
783
1396
  if (!editor || blocks.length === 0)
784
1397
  return false;
@@ -809,19 +1422,130 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
809
1422
  return position & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
810
1423
  });
811
1424
  const list = document.createElement(listTag);
1425
+ if (styleType)
1426
+ list.style.listStyleType = styleType;
812
1427
  parent.insertBefore(list, group[0]);
813
1428
  group.forEach((block) => {
814
1429
  const li = document.createElement("li");
815
1430
  li.innerHTML = block.innerHTML || "<br>";
1431
+ li.style.textAlign = block.style.textAlign;
816
1432
  list.appendChild(li);
817
1433
  block.remove();
818
1434
  lastLi = li;
819
1435
  });
1436
+ mergeAdjacentCompatibleLists(list);
820
1437
  });
821
1438
  if (lastLi)
822
1439
  focusElementEnd(lastLi);
823
1440
  return true;
824
1441
  };
1442
+ const convertTableCellSelectionToList = (range, listTag) => {
1443
+ if (range.collapsed)
1444
+ return false;
1445
+ const startCell = getClosestCell(range.startContainer);
1446
+ const endCell = getClosestCell(range.endContainer);
1447
+ if (!startCell || startCell !== endCell)
1448
+ return false;
1449
+ const fragment = range.extractContents();
1450
+ const parts = [document.createDocumentFragment()];
1451
+ Array.from(fragment.childNodes).forEach((node) => {
1452
+ if (node instanceof HTMLBRElement) {
1453
+ parts.push(document.createDocumentFragment());
1454
+ }
1455
+ else {
1456
+ parts[parts.length - 1].appendChild(node);
1457
+ }
1458
+ });
1459
+ const nonEmptyParts = parts.filter((part) => part.textContent?.trim() || part.childNodes.length > 0);
1460
+ if (nonEmptyParts.length === 0) {
1461
+ range.insertNode(fragment);
1462
+ return false;
1463
+ }
1464
+ const list = document.createElement(listTag);
1465
+ nonEmptyParts.forEach((part) => {
1466
+ const item = document.createElement("li");
1467
+ item.appendChild(part);
1468
+ if (!item.innerHTML.trim())
1469
+ item.innerHTML = "<br>";
1470
+ list.appendChild(item);
1471
+ });
1472
+ range.insertNode(list);
1473
+ const lastItem = list.lastElementChild;
1474
+ if (lastItem)
1475
+ focusElementEnd(lastItem);
1476
+ return true;
1477
+ };
1478
+ const convertRootLineSelectionToList = (range, listTag) => {
1479
+ const editor = editableRef.current;
1480
+ if (!editor || range.collapsed)
1481
+ return false;
1482
+ const closestBlock = (node) => {
1483
+ const element = node instanceof HTMLElement ? node : node.parentElement;
1484
+ const block = element?.closest(blockSelector);
1485
+ return block === editor ? null : block;
1486
+ };
1487
+ if (closestBlock(range.startContainer) || closestBlock(range.endContainer))
1488
+ return false;
1489
+ const lineRange = range.cloneRange();
1490
+ const breaks = Array.from(editor.querySelectorAll("br"));
1491
+ let previousBreak = null;
1492
+ let nextBreak = null;
1493
+ breaks.forEach((lineBreak) => {
1494
+ const parent = lineBreak.parentNode;
1495
+ if (!parent)
1496
+ return;
1497
+ const index = Array.prototype.indexOf.call(parent.childNodes, lineBreak);
1498
+ const beforeRelation = range.comparePoint(parent, index);
1499
+ const afterRelation = range.comparePoint(parent, index + 1);
1500
+ if (afterRelation === -1) {
1501
+ previousBreak = lineBreak;
1502
+ }
1503
+ else if (!nextBreak &&
1504
+ (beforeRelation === 1 || (beforeRelation === 0 && !range.intersectsNode(lineBreak)))) {
1505
+ nextBreak = lineBreak;
1506
+ }
1507
+ });
1508
+ if (previousBreak)
1509
+ lineRange.setStartAfter(previousBreak);
1510
+ else
1511
+ lineRange.setStart(editor, 0);
1512
+ if (nextBreak)
1513
+ lineRange.setEndBefore(nextBreak);
1514
+ else
1515
+ lineRange.setEnd(editor, editor.childNodes.length);
1516
+ const fragment = lineRange.extractContents();
1517
+ const parts = [document.createDocumentFragment()];
1518
+ Array.from(fragment.childNodes).forEach((node) => {
1519
+ if (node instanceof HTMLBRElement)
1520
+ parts.push(document.createDocumentFragment());
1521
+ else if (node instanceof HTMLElement && node.matches(blockSelector)) {
1522
+ if (parts[parts.length - 1].childNodes.length > 0)
1523
+ parts.push(document.createDocumentFragment());
1524
+ while (node.firstChild)
1525
+ parts[parts.length - 1].appendChild(node.firstChild);
1526
+ parts.push(document.createDocumentFragment());
1527
+ }
1528
+ else
1529
+ parts[parts.length - 1].appendChild(node);
1530
+ });
1531
+ const nonEmptyParts = parts.filter((part) => part.textContent?.length || part.childNodes.length > 0);
1532
+ if (nonEmptyParts.length === 0) {
1533
+ lineRange.insertNode(fragment);
1534
+ return false;
1535
+ }
1536
+ const list = document.createElement(listTag);
1537
+ nonEmptyParts.forEach((part) => {
1538
+ const item = document.createElement("li");
1539
+ item.appendChild(part);
1540
+ list.appendChild(item);
1541
+ });
1542
+ lineRange.insertNode(list);
1543
+ const mergedList = mergeAdjacentCompatibleLists(list);
1544
+ const lastItem = mergedList.lastElementChild;
1545
+ if (lastItem)
1546
+ focusElementEnd(lastItem);
1547
+ return true;
1548
+ };
825
1549
  const transformSelectedListItems = (items, listTag) => {
826
1550
  const editor = editableRef.current;
827
1551
  if (!editor || items.length === 0)
@@ -860,6 +1584,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
860
1584
  flushPendingList();
861
1585
  const paragraph = document.createElement("p");
862
1586
  paragraph.innerHTML = child.innerHTML || "<br>";
1587
+ paragraph.style.textAlign = child.style.textAlign;
863
1588
  parent.insertBefore(paragraph, list);
864
1589
  lastTarget = paragraph;
865
1590
  child.remove();
@@ -919,6 +1644,21 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
919
1644
  requestAnimationFrame(updateActiveState);
920
1645
  return;
921
1646
  }
1647
+ if (blocks.length === 0) {
1648
+ pushEditorHistory();
1649
+ if (convertRootLineSelectionToList(range, listTag)) {
1650
+ setListActiveState(true);
1651
+ handleInput();
1652
+ requestAnimationFrame(updateActiveState);
1653
+ return;
1654
+ }
1655
+ if (convertTableCellSelectionToList(range, listTag)) {
1656
+ setListActiveState(true);
1657
+ handleInput();
1658
+ requestAnimationFrame(updateActiveState);
1659
+ return;
1660
+ }
1661
+ }
922
1662
  }
923
1663
  const block = getCurrentBlock();
924
1664
  if (!block) {
@@ -1006,19 +1746,69 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1006
1746
  const selected = sortInDocumentOrder(blocks).filter((block) => {
1007
1747
  if (!editor.contains(block) || block === editor)
1008
1748
  return false;
1009
- if (block.closest("ul,ol"))
1010
- return false;
1011
1749
  if (block.tagName.toLowerCase() === "blockquote")
1012
1750
  return false;
1013
1751
  return Boolean(block.parentElement);
1014
1752
  });
1015
1753
  if (selected.length === 0)
1016
1754
  return false;
1755
+ const mergeQuote = (quote) => {
1756
+ let merged = quote;
1757
+ const previous = merged.previousElementSibling;
1758
+ if (previous?.tagName === "BLOCKQUOTE") {
1759
+ while (merged.firstChild)
1760
+ previous.appendChild(merged.firstChild);
1761
+ merged.remove();
1762
+ merged = previous;
1763
+ }
1764
+ const next = merged.nextElementSibling;
1765
+ if (next?.tagName === "BLOCKQUOTE") {
1766
+ while (next.firstChild)
1767
+ merged.appendChild(next.firstChild);
1768
+ next.remove();
1769
+ }
1770
+ return merged;
1771
+ };
1772
+ const selectedItems = new Set(getSelectedListItems(selected));
1773
+ const sourceLists = Array.from(new Set(Array.from(selectedItems, (item) => item.parentElement)));
1017
1774
  let lastWrapped = null;
1018
- selected.forEach((block) => {
1775
+ sourceLists.forEach((source) => {
1776
+ const parent = source.parentElement;
1777
+ if (!parent)
1778
+ return;
1779
+ let pending = null;
1780
+ let pendingSelected = null;
1781
+ const outputs = [];
1782
+ Array.from(source.children).forEach((child) => {
1783
+ if (!(child instanceof HTMLElement) || child.tagName !== "LI")
1784
+ return;
1785
+ const isSelected = selectedItems.has(child);
1786
+ if (!pending || pendingSelected !== isSelected) {
1787
+ pending = cloneListShell(source);
1788
+ pendingSelected = isSelected;
1789
+ outputs.push({ list: pending, selected: isSelected });
1790
+ }
1791
+ pending.appendChild(child);
1792
+ });
1793
+ outputs.forEach((output) => {
1794
+ if (output.selected) {
1795
+ const quote = document.createElement("blockquote");
1796
+ quote.appendChild(output.list);
1797
+ parent.insertBefore(quote, source);
1798
+ mergeQuote(quote);
1799
+ lastWrapped = output.list.lastElementChild;
1800
+ }
1801
+ else {
1802
+ parent.insertBefore(output.list, source);
1803
+ }
1804
+ });
1805
+ source.remove();
1806
+ });
1807
+ selected.filter((block) => !block.closest("ul,ol")).forEach((block) => {
1019
1808
  const quote = document.createElement("blockquote");
1020
1809
  block.parentElement?.insertBefore(quote, block);
1021
1810
  quote.appendChild(block);
1811
+ mergeQuote(quote);
1022
1812
  lastWrapped = block;
1023
1813
  });
1024
1814
  if (lastWrapped)
@@ -1044,93 +1834,243 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
1044
1834
  const block = getCurrentBlock();
1045
1835
  if (block) {
1046
1836
  pushEditorHistory();
1837
+ const currentList = block.closest("ul,ol");
1838
+ if (currentList && editor.contains(currentList)) {
1839
+ const quote = document.createElement("blockquote");
1840
+ currentList.parentElement?.insertBefore(quote, currentList);
1841
+ quote.appendChild(currentList);
1842
+ focusElementEnd(block);
1843
+ handleInput();
1844
+ requestAnimationFrame(updateActiveState);
1845
+ return;
1846
+ }
1047
1847
  if (!wrapBlocks([block]))
1048
1848
  return;
1049
1849
  handleInput();
1050
1850
  requestAnimationFrame(updateActiveState);
1051
1851
  return;
1052
1852
  }
1053
- }
1054
- else {
1055
- const blocks = getSelectedBlocks(range);
1056
- if (blocks.length > 0) {
1057
- pushEditorHistory();
1058
- if (!wrapBlocks(blocks))
1059
- return;
1060
- handleInput();
1061
- requestAnimationFrame(updateActiveState);
1853
+ }
1854
+ else {
1855
+ const blocks = getSelectedBlocks(range);
1856
+ if (blocks.length > 0) {
1857
+ pushEditorHistory();
1858
+ if (!wrapBlocks(blocks))
1859
+ return;
1860
+ handleInput();
1861
+ requestAnimationFrame(updateActiveState);
1862
+ return;
1863
+ }
1864
+ }
1865
+ pushEditorHistory();
1866
+ exec("formatBlock", "<blockquote>");
1867
+ }
1868
+ catch { }
1869
+ };
1870
+ const toggleCodeBlock = () => {
1871
+ try {
1872
+ if (!restoreSavedSelection())
1873
+ safeSelectRange(getSelectionRangeInEditor());
1874
+ const editor = editableRef.current;
1875
+ const range = getSelectionRangeInEditor();
1876
+ if (!editor || !range)
1877
+ return;
1878
+ const blocks = (range.collapsed
1879
+ ? [getCurrentBlock()].filter((block) => Boolean(block))
1880
+ : getSelectedBlocks(range)).filter((block) => editor.contains(block));
1881
+ if (blocks.length === 0)
1882
+ return;
1883
+ const listItems = getSelectedListItems(blocks);
1884
+ const plainBlocks = blocks.filter((block) => !block.closest("ul,ol") && !block.closest("table"));
1885
+ const getItemCode = (item) => item.querySelector(":scope > pre[data-srte-list-code]");
1886
+ const allTargetsActive = listItems.every((item) => Boolean(getItemCode(item))) &&
1887
+ plainBlocks.every((block) => block.tagName === "PRE") &&
1888
+ listItems.length + plainBlocks.length > 0;
1889
+ pushEditorHistory();
1890
+ let lastTarget = null;
1891
+ listItems.forEach((item) => {
1892
+ const existing = getItemCode(item);
1893
+ if (allTargetsActive && existing) {
1894
+ const code = existing.querySelector(":scope > code");
1895
+ const source = code || existing;
1896
+ while (source.firstChild)
1897
+ item.insertBefore(source.firstChild, existing);
1898
+ existing.remove();
1899
+ lastTarget = item;
1900
+ return;
1901
+ }
1902
+ if (existing) {
1903
+ lastTarget = existing;
1062
1904
  return;
1063
1905
  }
1064
- }
1065
- pushEditorHistory();
1066
- exec("formatBlock", "<blockquote>");
1906
+ const pre = document.createElement("pre");
1907
+ pre.dataset.srteListCode = "true";
1908
+ pre.style.textAlign = "left";
1909
+ const code = document.createElement("code");
1910
+ const nestedList = Array.from(item.children).find((child) => child.matches("ul,ol")) || null;
1911
+ Array.from(item.childNodes).forEach((node) => {
1912
+ if (node === nestedList)
1913
+ return;
1914
+ if (node instanceof HTMLElement && node.dataset.srteCheck === "true")
1915
+ return;
1916
+ code.appendChild(node);
1917
+ });
1918
+ if (!code.childNodes.length)
1919
+ code.innerHTML = "<br>";
1920
+ pre.appendChild(code);
1921
+ item.insertBefore(pre, nestedList);
1922
+ lastTarget = pre;
1923
+ });
1924
+ sortInDocumentOrder(plainBlocks).forEach((block) => {
1925
+ if (allTargetsActive && block.tagName === "PRE") {
1926
+ const paragraph = document.createElement("p");
1927
+ const code = block.querySelector(":scope > code");
1928
+ paragraph.innerHTML = code?.innerHTML || block.innerHTML || "<br>";
1929
+ block.parentElement?.replaceChild(paragraph, block);
1930
+ lastTarget = paragraph;
1931
+ }
1932
+ else if (block.tagName !== "PRE") {
1933
+ const pre = replaceBlockTag(block, "pre");
1934
+ pre.style.textAlign = "left";
1935
+ if (!pre.querySelector(":scope > code")) {
1936
+ const code = document.createElement("code");
1937
+ while (pre.firstChild)
1938
+ code.appendChild(pre.firstChild);
1939
+ pre.appendChild(code);
1940
+ }
1941
+ lastTarget = pre;
1942
+ }
1943
+ });
1944
+ if (lastTarget)
1945
+ focusElementEnd(lastTarget);
1946
+ handleInput();
1947
+ requestAnimationFrame(updateActiveState);
1067
1948
  }
1068
1949
  catch { }
1069
1950
  };
1070
1951
  const applyFontSize = (size) => {
1071
1952
  try {
1072
- // Update current font size state
1073
- setCurrentFontSize(size);
1074
1953
  const editor = editableRef.current;
1075
1954
  if (!editor)
1076
1955
  return;
1077
- editor.focus();
1078
- // Try to get current selection, or use saved range
1079
- let range = null;
1080
- const sel = window.getSelection();
1081
- if (sel && sel.rangeCount > 0) {
1082
- const currentRange = sel.getRangeAt(0);
1083
- // Use current range if it's within our editor
1084
- if (editor.contains(currentRange.commonAncestorContainer)) {
1085
- range = currentRange;
1086
- }
1087
- }
1088
- // Fallback to saved range if current range is not available
1089
- if (!range && savedRangeRef.current) {
1090
- range = savedRangeRef.current.cloneRange();
1091
- }
1092
- // If no range at all, just update state for future typing
1956
+ const valuePx = Number(size);
1957
+ if (!Number.isFinite(valuePx) || valuePx <= 0)
1958
+ return;
1959
+ if (!restoreSavedSelection())
1960
+ safeSelectRange(getSelectionRangeInEditor());
1961
+ const range = getSelectionRangeInEditor();
1093
1962
  if (!range)
1094
1963
  return;
1095
- // If range is collapsed (cursor position, no selection), insert an invisible span
1964
+ setCurrentFontSize(String(Math.round(valuePx)));
1096
1965
  if (range.collapsed) {
1097
- // Create a span with zero-width space that will capture future typing
1098
- const span = document.createElement('span');
1099
- span.style.fontSize = size + 'pt';
1100
- span.textContent = '\u200B'; // Zero-width space
1101
- range.insertNode(span);
1102
- // Position cursor inside the span
1103
- const newRange = document.createRange();
1104
- newRange.setStart(span.firstChild, 1);
1105
- newRange.collapse(true);
1106
- if (sel) {
1107
- sel.removeAllRanges();
1108
- sel.addRange(newRange);
1109
- }
1110
- handleInput();
1966
+ pendingFontSizeRef.current = {
1967
+ valuePx,
1968
+ container: range.startContainer,
1969
+ offset: range.startOffset,
1970
+ };
1971
+ savedRangeRef.current = range.cloneRange();
1111
1972
  return;
1112
1973
  }
1113
- // If there's selected text, wrap it
1114
- const span = document.createElement('span');
1115
- span.style.fontSize = size + 'pt';
1116
- // Extract the selected content and wrap it in the span
1117
- const fragment = range.extractContents();
1118
- span.appendChild(fragment);
1119
- // Insert the span at the current position
1120
- range.insertNode(span);
1121
- // Update selection to show what was changed
1122
- if (sel) {
1123
- range.selectNodeContents(span);
1124
- sel.removeAllRanges();
1125
- sel.addRange(range);
1974
+ pushEditorHistory();
1975
+ pendingFontSizeRef.current = null;
1976
+ const textNodes = [];
1977
+ const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
1978
+ let candidate = walker.nextNode();
1979
+ while (candidate) {
1980
+ const text = candidate;
1981
+ const owner = text.parentElement;
1982
+ try {
1983
+ if (text.data.length > 0 && range.intersectsNode(text) &&
1984
+ !owner?.closest('[contenteditable="false"],button,[data-srte-editor-only="true"]'))
1985
+ textNodes.push(text);
1986
+ }
1987
+ catch { }
1988
+ candidate = walker.nextNode();
1989
+ }
1990
+ const selectedTexts = [];
1991
+ [...textNodes].reverse().forEach((text) => {
1992
+ const start = text === range.startContainer ? range.startOffset : 0;
1993
+ const end = text === range.endContainer ? range.endOffset : text.data.length;
1994
+ if (end <= start)
1995
+ return;
1996
+ if (end < text.data.length)
1997
+ text.splitText(end);
1998
+ const selected = start > 0 ? text.splitText(start) : text;
1999
+ const parent = selected.parentElement;
2000
+ if (parent?.tagName === "SPAN" &&
2001
+ parent.childNodes.length === 1 &&
2002
+ parent.textContent === selected.data) {
2003
+ parent.style.fontSize = `${valuePx}px`;
2004
+ }
2005
+ else {
2006
+ const span = document.createElement("span");
2007
+ span.style.fontSize = `${valuePx}px`;
2008
+ selected.parentNode?.insertBefore(span, selected);
2009
+ span.appendChild(selected);
2010
+ }
2011
+ selectedTexts.unshift(selected);
2012
+ });
2013
+ if (selectedTexts.length > 0) {
2014
+ normalizeFontSizeSpans(editor);
2015
+ const nextRange = document.createRange();
2016
+ nextRange.setStart(selectedTexts[0], 0);
2017
+ const last = selectedTexts[selectedTexts.length - 1];
2018
+ nextRange.setEnd(last, last.data.length);
2019
+ safeSelectRange(nextRange);
2020
+ savedRangeRef.current = nextRange.cloneRange();
1126
2021
  }
1127
- // Trigger change event
1128
2022
  handleInput();
2023
+ requestAnimationFrame(updateActiveState);
1129
2024
  }
1130
2025
  catch (error) {
1131
2026
  console.error('Error applying font size:', error);
1132
2027
  }
1133
2028
  };
2029
+ useEffect(() => {
2030
+ const editor = editableRef.current;
2031
+ if (!editor)
2032
+ return;
2033
+ const applyPendingFontSize = (event) => {
2034
+ const pending = pendingFontSizeRef.current;
2035
+ if (!pending || event.inputType !== "insertText" || !event.data)
2036
+ return;
2037
+ const range = getSelectionRangeInEditor();
2038
+ if (!range?.collapsed ||
2039
+ range.startContainer !== pending.container ||
2040
+ range.startOffset !== pending.offset)
2041
+ return;
2042
+ event.preventDefault();
2043
+ event.stopPropagation();
2044
+ pushEditorHistory();
2045
+ const text = document.createTextNode(event.data);
2046
+ const sizedAncestor = range.startContainer instanceof HTMLElement
2047
+ ? range.startContainer.closest("span")
2048
+ : range.startContainer.parentElement?.closest("span");
2049
+ if (sizedAncestor instanceof HTMLElement &&
2050
+ parseFontSizePx(sizedAncestor.style.fontSize) === pending.valuePx) {
2051
+ range.insertNode(text);
2052
+ }
2053
+ else {
2054
+ const span = document.createElement("span");
2055
+ span.style.fontSize = `${pending.valuePx}px`;
2056
+ span.appendChild(text);
2057
+ range.insertNode(span);
2058
+ }
2059
+ const nextRange = document.createRange();
2060
+ nextRange.setStartAfter(text);
2061
+ nextRange.collapse(true);
2062
+ safeSelectRange(nextRange);
2063
+ pendingFontSizeRef.current = {
2064
+ valuePx: pending.valuePx,
2065
+ container: nextRange.startContainer,
2066
+ offset: nextRange.startOffset,
2067
+ };
2068
+ savedRangeRef.current = nextRange.cloneRange();
2069
+ handleInput();
2070
+ };
2071
+ editor.addEventListener("beforeinput", applyPendingFontSize);
2072
+ return () => editor.removeEventListener("beforeinput", applyPendingFontSize);
2073
+ }, []);
1134
2074
  const applyFontFamily = (font) => {
1135
2075
  try {
1136
2076
  setCurrentFont(font);
@@ -2218,164 +3158,6 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2218
3158
  .replace(/&/g, "&amp;")
2219
3159
  .replace(/</g, "&lt;")
2220
3160
  .replace(/>/g, "&gt;");
2221
- const escapeHtmlAttribute = (value) => escapeHtml(value).replace(/"/g, "&quot;");
2222
- const markdownToHtml = (markdown) => {
2223
- const lines = markdown.replace(/\r\n/g, "\n").split("\n");
2224
- let html = "";
2225
- let listType = null;
2226
- let paragraph = [];
2227
- let codeFence = null;
2228
- const closeList = () => {
2229
- if (listType) {
2230
- html += `</${listType}>`;
2231
- listType = null;
2232
- }
2233
- };
2234
- const inline = (text) => {
2235
- const codeTokens = [];
2236
- let value = text.replace(/`([^`]+)`/g, (_match, code) => {
2237
- const token = `@@SRTE_CODE_${codeTokens.length}@@`;
2238
- codeTokens.push(`<code>${escapeHtml(code)}</code>`);
2239
- return token;
2240
- });
2241
- value = escapeHtml(value)
2242
- .replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_match, alt, src, title) => {
2243
- const titleAttr = title ? ` title="${escapeHtmlAttribute(title)}"` : "";
2244
- return `<img src="${escapeHtmlAttribute(src)}" alt="${escapeHtmlAttribute(alt)}"${titleAttr}>`;
2245
- })
2246
- .replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_match, label, href, title) => {
2247
- const titleAttr = title ? ` title="${escapeHtmlAttribute(title)}"` : "";
2248
- return `<a href="${escapeHtmlAttribute(href)}"${titleAttr}>${label}</a>`;
2249
- })
2250
- .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
2251
- .replace(/__([^_]+)__/g, "<strong>$1</strong>")
2252
- .replace(/~~([^~]+)~~/g, "<s>$1</s>")
2253
- .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
2254
- .replace(/(^|[^_])_([^_\n]+)_/g, "$1<em>$2</em>");
2255
- codeTokens.forEach((replacement, index) => {
2256
- value = value.replace(`@@SRTE_CODE_${index}@@`, replacement);
2257
- });
2258
- return value;
2259
- };
2260
- const closeParagraph = () => {
2261
- if (!paragraph.length)
2262
- return;
2263
- html += `<p>${inline(paragraph.join(" "))}</p>`;
2264
- paragraph = [];
2265
- };
2266
- const isTableSeparator = (line) => /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
2267
- const parseTableRow = (line) => {
2268
- let value = line.trim();
2269
- if (value.startsWith("|"))
2270
- value = value.slice(1);
2271
- if (value.endsWith("|"))
2272
- value = value.slice(0, -1);
2273
- return value.split("|").map((cell) => cell.trim());
2274
- };
2275
- const renderTable = (startIndex) => {
2276
- const header = parseTableRow(lines[startIndex]);
2277
- let index = startIndex + 2;
2278
- const rows = [];
2279
- while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
2280
- rows.push(parseTableRow(lines[index]));
2281
- index += 1;
2282
- }
2283
- const headHtml = `<thead><tr>${header.map((cell) => `<th>${inline(cell)}</th>`).join("")}</tr></thead>`;
2284
- const bodyHtml = rows.length
2285
- ? `<tbody>${rows.map((row) => `<tr>${header.map((_cell, cellIndex) => `<td>${inline(row[cellIndex] || "")}</td>`).join("")}</tr>`).join("")}</tbody>`
2286
- : "";
2287
- html += `<table style="border-collapse: collapse; width: 100%; margin: 12px 0;">${headHtml}${bodyHtml}</table>`;
2288
- return index;
2289
- };
2290
- for (let i = 0; i < lines.length; i += 1) {
2291
- const line = lines[i];
2292
- const trimmed = line.trim();
2293
- const fence = /^```([A-Za-z0-9_-]+)?\s*$/.exec(trimmed);
2294
- if (fence) {
2295
- closeParagraph();
2296
- closeList();
2297
- if (codeFence) {
2298
- const langClass = codeFence.lang ? ` class="language-${escapeHtmlAttribute(codeFence.lang)}"` : "";
2299
- html += `<pre><code${langClass}>${escapeHtml(codeFence.lines.join("\n"))}</code></pre>`;
2300
- codeFence = null;
2301
- }
2302
- else {
2303
- codeFence = { lang: fence[1] || "", lines: [] };
2304
- }
2305
- continue;
2306
- }
2307
- if (codeFence) {
2308
- codeFence.lines.push(line);
2309
- continue;
2310
- }
2311
- if (!trimmed) {
2312
- closeParagraph();
2313
- closeList();
2314
- continue;
2315
- }
2316
- if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
2317
- closeParagraph();
2318
- closeList();
2319
- html += "<hr>";
2320
- continue;
2321
- }
2322
- if (i + 1 < lines.length && trimmed.includes("|") && isTableSeparator(lines[i + 1])) {
2323
- closeParagraph();
2324
- closeList();
2325
- i = renderTable(i) - 1;
2326
- continue;
2327
- }
2328
- const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
2329
- if (heading) {
2330
- closeParagraph();
2331
- closeList();
2332
- const level = heading[1].length;
2333
- html += `<h${level}>${inline(heading[2])}</h${level}>`;
2334
- continue;
2335
- }
2336
- const bullet = /^[-*+]\s+(.+)$/.exec(trimmed);
2337
- if (bullet) {
2338
- closeParagraph();
2339
- if (listType !== "ul") {
2340
- closeList();
2341
- html += "<ul>";
2342
- listType = "ul";
2343
- }
2344
- html += `<li>${inline(bullet[1])}</li>`;
2345
- continue;
2346
- }
2347
- const numbered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
2348
- if (numbered) {
2349
- closeParagraph();
2350
- if (listType !== "ol") {
2351
- closeList();
2352
- html += "<ol>";
2353
- listType = "ol";
2354
- }
2355
- html += `<li>${inline(numbered[1])}</li>`;
2356
- continue;
2357
- }
2358
- const quote = /^>\s?(.*)$/.exec(trimmed);
2359
- if (quote) {
2360
- closeParagraph();
2361
- closeList();
2362
- html += `<blockquote>${inline(quote[1]) || "<br>"}</blockquote>`;
2363
- continue;
2364
- }
2365
- closeList();
2366
- paragraph.push(trimmed);
2367
- }
2368
- if (codeFence) {
2369
- const langClass = codeFence.lang ? ` class="language-${escapeHtmlAttribute(codeFence.lang)}"` : "";
2370
- html += `<pre><code${langClass}>${escapeHtml(codeFence.lines.join("\n"))}</code></pre>`;
2371
- }
2372
- closeParagraph();
2373
- closeList();
2374
- const root = document.createElement("div");
2375
- root.innerHTML = html;
2376
- enhanceImportedTables(root);
2377
- return root.innerHTML;
2378
- };
2379
3161
  const htmlToMarkdown = (html) => {
2380
3162
  const root = document.createElement("div");
2381
3163
  root.innerHTML = html;
@@ -2419,7 +3201,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2419
3201
  return;
2420
3202
  const file = files[0];
2421
3203
  const text = await file.text();
2422
- const html = type === "html" ? text : markdownToHtml(text);
3204
+ const html = type === "html" ? text : markdownToCompatibilityHtml(text);
2423
3205
  const el = editableRef.current;
2424
3206
  const hasContent = el && el.textContent && el.textContent.trim().length > 0;
2425
3207
  insertImportedHtml(html, hasContent ? "append" : "replace", {
@@ -2885,11 +3667,73 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2885
3667
  console.error("Error wrapping tables", e);
2886
3668
  }
2887
3669
  };
3670
+ const normalizeInvalidTableNesting = (root) => {
3671
+ const getOuterList = (item) => {
3672
+ let list = item.parentElement;
3673
+ while (list?.parentElement?.tagName === "LI") {
3674
+ const parentList = list.parentElement.parentElement;
3675
+ if (!parentList || !["UL", "OL"].includes(parentList.tagName))
3676
+ break;
3677
+ list = parentList;
3678
+ }
3679
+ return list;
3680
+ };
3681
+ root.querySelectorAll("table").forEach((table) => {
3682
+ const tableBlock = (table.closest('[data-table-wrapper="true"]') || table);
3683
+ const codeBlock = tableBlock.closest("pre");
3684
+ if (codeBlock?.parentElement) {
3685
+ codeBlock.parentElement.insertBefore(tableBlock, codeBlock.nextSibling);
3686
+ return;
3687
+ }
3688
+ const listItem = tableBlock.closest("li");
3689
+ if (!listItem)
3690
+ return;
3691
+ const outerList = getOuterList(listItem);
3692
+ if (outerList?.parentElement) {
3693
+ outerList.parentElement.insertBefore(tableBlock, outerList.nextSibling);
3694
+ }
3695
+ });
3696
+ };
3697
+ const normalizeInvalidQuoteNesting = (root) => {
3698
+ root.querySelectorAll("blockquote blockquote").forEach((quote) => {
3699
+ const outerQuote = quote.parentElement?.closest("blockquote");
3700
+ if (outerQuote?.parentElement) {
3701
+ outerQuote.parentElement.insertBefore(quote, outerQuote.nextSibling);
3702
+ }
3703
+ });
3704
+ root.querySelectorAll("p,h1,h2,h3,h4,h5,h6,pre").forEach((container) => {
3705
+ const nestedQuotes = Array.from(container.children).filter((child) => child.tagName === "BLOCKQUOTE");
3706
+ nestedQuotes.forEach((quote) => {
3707
+ container.parentElement?.insertBefore(quote, container.nextSibling);
3708
+ });
3709
+ if (nestedQuotes.length > 0 &&
3710
+ container.tagName === "P" &&
3711
+ !container.textContent?.trim() &&
3712
+ Array.from(container.children).every((child) => child.tagName === "BR")) {
3713
+ container.remove();
3714
+ }
3715
+ });
3716
+ };
3717
+ const normalizeInvalidCodeBlockNesting = (root) => {
3718
+ root.querySelectorAll("pre pre").forEach((codeBlock) => {
3719
+ const outerCodeBlock = codeBlock.parentElement?.closest("pre");
3720
+ if (outerCodeBlock?.parentElement) {
3721
+ outerCodeBlock.parentElement.insertBefore(codeBlock, outerCodeBlock.nextSibling);
3722
+ }
3723
+ });
3724
+ root.querySelectorAll("p,h1,h2,h3,h4,h5,h6,blockquote").forEach((container) => {
3725
+ const nestedCodeBlocks = Array.from(container.children).filter((child) => child.tagName === "PRE");
3726
+ nestedCodeBlocks.forEach((codeBlock) => {
3727
+ container.parentElement?.insertBefore(codeBlock, container.nextSibling);
3728
+ });
3729
+ });
3730
+ };
2888
3731
  const isCaretBoundaryBlock = (node) => {
2889
3732
  if (!(node instanceof HTMLElement))
2890
3733
  return false;
2891
3734
  const tag = node.tagName.toLowerCase();
2892
3735
  return (tag === "blockquote" ||
3736
+ tag === "pre" ||
2893
3737
  tag === "table" ||
2894
3738
  node.getAttribute("data-table-wrapper") === "true");
2895
3739
  };
@@ -2922,6 +3766,12 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
2922
3766
  return;
2923
3767
  // Auto-fix negative margins that might cause visibility issues
2924
3768
  fixNegativeMargins(el);
3769
+ // Quotes are document blocks and cannot be nested by drag and drop.
3770
+ normalizeInvalidQuoteNesting(el);
3771
+ // Code blocks are document blocks and cannot be nested by drag and drop.
3772
+ normalizeInvalidCodeBlockNesting(el);
3773
+ // Tables are document-level blocks and must not remain inside code or list items.
3774
+ normalizeInvalidTableNesting(el);
2925
3775
  // Ensure tables are wrapped for horizontal scrolling
2926
3776
  ensureTableWrappers(el);
2927
3777
  // Keep a reachable typing position around isolating blocks at document edges
@@ -3509,13 +4359,19 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3509
4359
  if (image && editor.contains(image)) {
3510
4360
  return (image.parentElement?.tagName === "A" ? image.parentElement : image);
3511
4361
  }
4362
+ const listElement = element.closest("ul,ol");
4363
+ if (listElement && editor.contains(listElement))
4364
+ return listElement;
3512
4365
  const tableElement = element.closest("table");
3513
4366
  if (tableElement && editor.contains(tableElement)) {
3514
4367
  return (tableElement.closest('[data-table-wrapper="true"]') || tableElement);
3515
4368
  }
3516
- const listElement = element.closest("ul,ol");
3517
- if (listElement && editor.contains(listElement))
3518
- return listElement;
4369
+ const quoteElement = element.closest("blockquote");
4370
+ if (quoteElement && editor.contains(quoteElement))
4371
+ return quoteElement;
4372
+ const codeBlock = element.closest("pre");
4373
+ if (codeBlock && editor.contains(codeBlock))
4374
+ return codeBlock;
3519
4375
  const block = element.closest('[data-table-wrapper="true"],blockquote,pre,p,h1,h2,h3,h4,h5,h6,div');
3520
4376
  if (!block || block === editor || !editor.contains(block))
3521
4377
  return null;
@@ -3535,8 +4391,13 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3535
4391
  }
3536
4392
  const targetRect = target.getBoundingClientRect();
3537
4393
  const scrollRect = scroller.getBoundingClientRect();
4394
+ const isTableTarget = target.matches("table,[data-table-wrapper='true']") ||
4395
+ Boolean(target.querySelector("table"));
4396
+ const isListInsideCell = target.matches("ul,ol") &&
4397
+ Boolean(target.closest("td,th"));
3538
4398
  const next = {
3539
- left: Math.max(4, targetRect.left - scrollRect.left + scroller.scrollLeft - 30),
4399
+ // Table handles sit on the table's left border so nested tables remain reachable.
4400
+ left: Math.max(4, targetRect.left - scrollRect.left + scroller.scrollLeft - (isTableTarget || isListInsideCell ? 12 : 30)),
3540
4401
  top: targetRect.top - scrollRect.top + scroller.scrollTop,
3541
4402
  height: Math.max(24, targetRect.height),
3542
4403
  target,
@@ -3559,7 +4420,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3559
4420
  dragHandleHideTimerRef.current = null;
3560
4421
  if (!draggedBlockRef.current)
3561
4422
  setDragHandle(null);
3562
- }, 120);
4423
+ }, 350);
3563
4424
  };
3564
4425
  const getImageFromMovableBlock = (block) => {
3565
4426
  if (block.tagName === "IMG")
@@ -3571,6 +4432,50 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3571
4432
  if (!editor || !editor.contains(block))
3572
4433
  return false;
3573
4434
  const under = document.elementFromPoint(x, y);
4435
+ const draggedQuote = block.tagName === "BLOCKQUOTE";
4436
+ const draggedCodeBlock = block.tagName === "PRE";
4437
+ if (draggedQuote) {
4438
+ const underElement = under instanceof HTMLElement ? under : under?.parentElement;
4439
+ const quoteTarget = underElement?.closest("blockquote");
4440
+ if (quoteTarget && quoteTarget !== block) {
4441
+ let rootQuote = quoteTarget;
4442
+ while (rootQuote.parentElement?.closest("blockquote")) {
4443
+ rootQuote = rootQuote.parentElement.closest("blockquote");
4444
+ }
4445
+ const parent = rootQuote.parentElement;
4446
+ if (!parent)
4447
+ return false;
4448
+ const rect = rootQuote.getBoundingClientRect();
4449
+ if (rootQuote.contains(block) || y >= rect.top + rect.height / 2) {
4450
+ parent.insertBefore(block, rootQuote.nextSibling);
4451
+ }
4452
+ else {
4453
+ parent.insertBefore(block, rootQuote);
4454
+ }
4455
+ return true;
4456
+ }
4457
+ }
4458
+ if (draggedCodeBlock) {
4459
+ const underElement = under instanceof HTMLElement ? under : under?.parentElement;
4460
+ const codeTarget = underElement?.closest("pre");
4461
+ if (codeTarget && codeTarget !== block) {
4462
+ let rootCodeBlock = codeTarget;
4463
+ while (rootCodeBlock.parentElement?.closest("pre")) {
4464
+ rootCodeBlock = rootCodeBlock.parentElement.closest("pre");
4465
+ }
4466
+ const parent = rootCodeBlock.parentElement;
4467
+ if (!parent)
4468
+ return false;
4469
+ const rect = rootCodeBlock.getBoundingClientRect();
4470
+ if (rootCodeBlock.contains(block) || y >= rect.top + rect.height / 2) {
4471
+ parent.insertBefore(block, rootCodeBlock.nextSibling);
4472
+ }
4473
+ else {
4474
+ parent.insertBefore(block, rootCodeBlock);
4475
+ }
4476
+ return true;
4477
+ }
4478
+ }
3574
4479
  const draggedImage = getImageFromMovableBlock(block);
3575
4480
  const targetCell = draggedImage ? getClosestCell(under) : null;
3576
4481
  if (draggedImage && targetCell && !block.contains(targetCell)) {
@@ -3601,6 +4506,78 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3601
4506
  if (range && editor.contains(range.commonAncestorContainer)) {
3602
4507
  if (block.contains(range.commonAncestorContainer))
3603
4508
  return false;
4509
+ if (draggedQuote) {
4510
+ const element = range.commonAncestorContainer instanceof HTMLElement
4511
+ ? range.commonAncestorContainer
4512
+ : range.commonAncestorContainer.parentElement;
4513
+ const container = element?.closest("p,h1,h2,h3,h4,h5,h6,pre");
4514
+ if (container?.parentElement) {
4515
+ const isEmpty = !container.textContent?.trim() &&
4516
+ Array.from(container.children).every((child) => child.tagName === "BR");
4517
+ if (isEmpty) {
4518
+ container.parentElement.insertBefore(block, container);
4519
+ container.remove();
4520
+ }
4521
+ else {
4522
+ const rect = container.getBoundingClientRect();
4523
+ container.parentElement.insertBefore(block, y < rect.top + rect.height / 2 ? container : container.nextSibling);
4524
+ }
4525
+ return true;
4526
+ }
4527
+ }
4528
+ if (draggedCodeBlock) {
4529
+ const element = range.commonAncestorContainer instanceof HTMLElement
4530
+ ? range.commonAncestorContainer
4531
+ : range.commonAncestorContainer.parentElement;
4532
+ const codeTarget = element?.closest("pre");
4533
+ if (codeTarget?.parentElement) {
4534
+ let rootCodeBlock = codeTarget;
4535
+ while (rootCodeBlock.parentElement?.closest("pre")) {
4536
+ rootCodeBlock = rootCodeBlock.parentElement.closest("pre");
4537
+ }
4538
+ rootCodeBlock.parentElement.insertBefore(block, rootCodeBlock.nextSibling);
4539
+ return true;
4540
+ }
4541
+ const container = element?.closest("p,h1,h2,h3,h4,h5,h6,blockquote");
4542
+ if (container?.parentElement) {
4543
+ const isEmpty = !container.textContent?.trim() &&
4544
+ Array.from(container.children).every((child) => child.tagName === "BR");
4545
+ if (isEmpty) {
4546
+ container.parentElement.insertBefore(block, container);
4547
+ container.remove();
4548
+ }
4549
+ else {
4550
+ const rect = container.getBoundingClientRect();
4551
+ container.parentElement.insertBefore(block, y < rect.top + rect.height / 2 ? container : container.nextSibling);
4552
+ }
4553
+ return true;
4554
+ }
4555
+ }
4556
+ const isTableBlock = block.matches("table,[data-table-wrapper='true']") || Boolean(block.querySelector("table"));
4557
+ if (isTableBlock) {
4558
+ const element = range.commonAncestorContainer instanceof HTMLElement
4559
+ ? range.commonAncestorContainer
4560
+ : range.commonAncestorContainer.parentElement;
4561
+ const codeBlock = element?.closest("pre");
4562
+ if (codeBlock?.parentElement) {
4563
+ codeBlock.parentElement.insertBefore(block, codeBlock.nextSibling);
4564
+ return true;
4565
+ }
4566
+ const listItem = element?.closest("li");
4567
+ if (listItem) {
4568
+ let outerList = listItem.parentElement;
4569
+ while (outerList?.parentElement?.tagName === "LI") {
4570
+ const parentList = outerList.parentElement.parentElement;
4571
+ if (!parentList || !["UL", "OL"].includes(parentList.tagName))
4572
+ break;
4573
+ outerList = parentList;
4574
+ }
4575
+ if (outerList?.parentElement) {
4576
+ outerList.parentElement.insertBefore(block, outerList.nextSibling);
4577
+ return true;
4578
+ }
4579
+ }
4580
+ }
3604
4581
  range.insertNode(block);
3605
4582
  return true;
3606
4583
  }
@@ -3671,13 +4648,65 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3671
4648
  };
3672
4649
  const elementSibling = (element, direction) => {
3673
4650
  let sibling = direction === "previous" ? element.previousSibling : element.nextSibling;
3674
- while (sibling && sibling.nodeType === Node.TEXT_NODE && !sibling.textContent?.trim()) {
4651
+ while (sibling &&
4652
+ ((sibling.nodeType === Node.TEXT_NODE && !sibling.textContent?.trim()) ||
4653
+ (sibling instanceof HTMLElement && sibling.getAttribute("data-srte-caret-boundary") === "true"))) {
3675
4654
  sibling = direction === "previous" ? sibling.previousSibling : sibling.nextSibling;
3676
4655
  }
3677
4656
  return sibling;
3678
4657
  };
4658
+ const moveSelectedBlocks = (direction) => {
4659
+ const editor = editableRef.current;
4660
+ const range = getSelectionRangeInEditor();
4661
+ if (!editor || !range || range.collapsed)
4662
+ return false;
4663
+ const blocks = sortInDocumentOrder(getSelectedBlocks(range)).filter((block) => {
4664
+ if (!editor.contains(block) || !block.parentElement)
4665
+ return false;
4666
+ const parentBlock = block.parentElement.closest(blockSelector);
4667
+ return !parentBlock || parentBlock === editor;
4668
+ });
4669
+ if (blocks.length === 0)
4670
+ return false;
4671
+ const parent = blocks[0].parentElement;
4672
+ if (!parent || blocks.some((block) => block.parentElement !== parent))
4673
+ return false;
4674
+ pushEditorHistory();
4675
+ if (direction === "up") {
4676
+ const previous = elementSibling(blocks[0], "previous");
4677
+ if (previous && !blocks.includes(previous)) {
4678
+ blocks.forEach((block) => parent.insertBefore(block, previous));
4679
+ }
4680
+ }
4681
+ else if (direction === "down") {
4682
+ const next = elementSibling(blocks[blocks.length - 1], "next");
4683
+ if (next && !blocks.includes(next)) {
4684
+ const afterNext = next.nextSibling;
4685
+ blocks.forEach((block) => parent.insertBefore(block, afterNext));
4686
+ }
4687
+ }
4688
+ else {
4689
+ blocks.forEach((block) => {
4690
+ const current = parseInt(block.style.marginLeft || "0", 10) || 0;
4691
+ const nextMargin = direction === "right"
4692
+ ? Math.min(current + 24, 240)
4693
+ : Math.max(current - 24, 0);
4694
+ block.style.marginLeft = nextMargin ? `${nextMargin}px` : "";
4695
+ });
4696
+ }
4697
+ const movedRange = document.createRange();
4698
+ movedRange.setStartBefore(blocks[0]);
4699
+ movedRange.setEndAfter(blocks[blocks.length - 1]);
4700
+ safeSelectRange(movedRange);
4701
+ savedRangeRef.current = movedRange.cloneRange();
4702
+ handleInput();
4703
+ requestAnimationFrame(updateActiveState);
4704
+ return true;
4705
+ };
3679
4706
  const moveCurrentElement = (direction) => {
3680
4707
  const editor = editableRef.current;
4708
+ if (moveSelectedBlocks(direction))
4709
+ return;
3681
4710
  let target = getMoveTarget();
3682
4711
  if (!editor || !target)
3683
4712
  return;
@@ -3859,7 +4888,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3859
4888
  } }), _jsx("input", { ref: mdInputRef, type: "file", accept: ".md,.markdown,text/markdown,text/plain", style: { display: "none" }, onChange: (e) => {
3860
4889
  importTextFile(e.currentTarget.files, "md");
3861
4890
  e.currentTarget.value = "";
3862
- } }), _jsxs("select", { value: currentBlockType, onMouseDown: preserveEditorSelection, onChange: (e) => {
4891
+ } }), _jsxs("select", { value: currentBlockType, onPointerDown: preserveEditorSelection, onMouseDown: preserveEditorSelection, onChange: (e) => {
3863
4892
  const val = e.target.value;
3864
4893
  if (val === "p")
3865
4894
  applyFormatBlock("<p>");
@@ -3869,6 +4898,12 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3869
4898
  applyFormatBlock("<h2>");
3870
4899
  else if (val === "h3")
3871
4900
  applyFormatBlock("<h3>");
4901
+ else if (val === "h4")
4902
+ applyFormatBlock("<h4>");
4903
+ else if (val === "h5")
4904
+ applyFormatBlock("<h5>");
4905
+ else if (val === "h6")
4906
+ applyFormatBlock("<h6>");
3872
4907
  }, title: "Paragraph/Heading", style: {
3873
4908
  height: 32,
3874
4909
  padding: "0 8px",
@@ -3876,24 +4911,23 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3876
4911
  borderRadius: 6,
3877
4912
  background: "var(--srte-input-bg)",
3878
4913
  color: "var(--srte-input-text)",
3879
- }, children: [_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("button", { title: "Bold", onClick: () => exec("bold"), "aria-pressed": activeState.bold, style: activeButtonStyle(activeState.bold), children: _jsx("span", { style: { fontWeight: 700 }, children: "B" }) }), _jsx("button", { title: "Italic", onClick: () => exec("italic"), "aria-pressed": activeState.italic, style: activeButtonStyle(activeState.italic, { fontStyle: "italic" }), children: "I" }), _jsx("button", { title: "Underline", onClick: () => exec("underline"), "aria-pressed": activeState.underline, style: activeButtonStyle(activeState.underline, { textDecoration: "underline" }), children: "U" }), _jsx("button", { title: "Strikethrough", onClick: () => exec("strikeThrough"), "aria-pressed": activeState.strikeThrough, style: activeButtonStyle(activeState.strikeThrough, { textDecoration: "line-through" }), children: "S" }), showFontSize && (_jsxs("select", { value: currentFontSize, onMouseDown: () => {
3880
- // Save selection before dropdown interaction
3881
- const sel = window.getSelection();
3882
- if (sel && sel.rangeCount > 0) {
3883
- const range = sel.getRangeAt(0);
3884
- const editor = editableRef.current;
3885
- if (editor && editor.contains(range.commonAncestorContainer) && !range.collapsed) {
3886
- savedRangeRef.current = range.cloneRange();
3887
- }
3888
- }
3889
- }, onChange: (e) => applyFontSize(e.target.value), title: "Font Size", style: {
4914
+ }, 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" })] }), [
4915
+ ["left", "Left", "Align left"],
4916
+ ["center", "Center", "Align center"],
4917
+ ["right", "Right", "Align right"],
4918
+ ["justify", "Justify", "Justify"],
4919
+ ].map(([alignment, label, title]) => (_jsx("button", { type: "button", title: title, "aria-label": title, "aria-pressed": currentAlignment === alignment, onPointerDown: preserveEditorSelection, onMouseDown: preserveEditorSelection, onClick: () => applyTextAlignment(alignment), style: activeButtonStyle(currentAlignment === alignment, {
4920
+ minWidth: 32,
4921
+ padding: "0 6px",
4922
+ fontSize: 10,
4923
+ }), children: label }, alignment))), _jsx("button", { title: "Bold", onClick: () => exec("bold"), "aria-pressed": activeState.bold, style: activeButtonStyle(activeState.bold), children: _jsx("span", { style: { fontWeight: 700 }, children: "B" }) }), _jsx("button", { title: "Italic", onClick: () => exec("italic"), "aria-pressed": activeState.italic, style: activeButtonStyle(activeState.italic, { fontStyle: "italic" }), children: "I" }), _jsx("button", { title: "Underline", onClick: () => exec("underline"), "aria-pressed": activeState.underline, style: activeButtonStyle(activeState.underline, { textDecoration: "underline" }), children: "U" }), _jsx("button", { 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: {
3890
4924
  height: 32,
3891
4925
  padding: "0 8px",
3892
4926
  border: "1px solid var(--srte-input-border)",
3893
4927
  borderRadius: 6,
3894
4928
  background: "var(--srte-input-bg)",
3895
4929
  color: "var(--srte-input-text)",
3896
- }, children: [_jsx("option", { value: "", disabled: true, children: "Size" }), _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: "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: () => {
4930
+ }, 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: () => {
3897
4931
  const sel = window.getSelection();
3898
4932
  if (sel && sel.rangeCount > 0) {
3899
4933
  const range = sel.getRangeAt(0);
@@ -3933,20 +4967,50 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3933
4967
  borderRadius: 6,
3934
4968
  background: "var(--srte-input-bg)",
3935
4969
  color: "var(--srte-input-text)",
3936
- }, children: _jsx("span", { style: { fontWeight: 700, padding: "1px 4px", background: "var(--srte-accent-bg)", borderRadius: 3 }, children: "A" }) }), _jsxs("button", { title: "Subscript", onClick: () => toggleInlineScript("subscript"), "aria-pressed": activeState.subscript, style: activeButtonStyle(activeState.subscript), children: ["X", _jsx("sub", { children: "2" })] }), _jsxs("button", { title: "Superscript", onClick: () => toggleInlineScript("superscript"), "aria-pressed": activeState.superscript, style: activeButtonStyle(activeState.superscript), children: ["X", _jsx("sup", { children: "2" })] }), _jsx("button", { title: "Bulleted list", onClick: () => exec("insertUnorderedList"), "aria-pressed": activeState.unorderedList, style: activeButtonStyle(activeState.unorderedList, { padding: "0 10px" }), children: "\u2022 List" }), _jsx("button", { title: "Numbered list", onClick: () => exec("insertOrderedList"), "aria-pressed": activeState.orderedList, style: activeButtonStyle(activeState.orderedList, { padding: "0 10px" }), children: "1. List" }), _jsxs("select", { defaultValue: "", onMouseDown: preserveEditorSelection, onChange: (e) => {
3937
- const value = e.target.value;
3938
- if (value)
3939
- applyListStyle(value);
3940
- e.currentTarget.value = "";
3941
- }, title: "List style", "aria-label": "List style", style: {
3942
- height: 32,
3943
- maxWidth: 112,
3944
- padding: "0 6px",
3945
- border: "1px solid var(--srte-input-border)",
3946
- borderRadius: 6,
3947
- background: "var(--srte-input-bg)",
3948
- color: "var(--srte-input-text)",
3949
- }, children: [_jsx("option", { value: "", disabled: true, children: "List style" }), _jsxs("optgroup", { label: "Bullets", children: [_jsx("option", { value: "bullet:disc", children: "Disc" }), _jsx("option", { value: "bullet:circle", children: "Circle" }), _jsx("option", { value: "bullet:square", children: "Square" })] }), _jsxs("optgroup", { label: "Numbered", children: [_jsx("option", { value: "ordered:decimal", children: "1, 2, 3" }), _jsx("option", { value: "ordered:lower-alpha", children: "a, b, c" }), _jsx("option", { value: "ordered:upper-alpha", children: "A, B, C" }), _jsx("option", { value: "ordered:lower-roman", children: "i, ii, iii" }), _jsx("option", { value: "ordered:upper-roman", children: "I, II, III" })] })] }), _jsx("button", { title: "Blockquote", onClick: toggleBlockquote, "aria-pressed": activeState.blockquote, style: activeButtonStyle(activeState.blockquote), children: "\u275D" }), _jsx("button", { title: "Special characters", onClick: () => setShowSpecialChars(true), style: {
4970
+ }, children: _jsx("span", { style: { fontWeight: 700, padding: "1px 4px", background: "var(--srte-accent-bg)", borderRadius: 3 }, children: "A" }) }), _jsxs("button", { title: "Subscript", onClick: () => toggleInlineScript("subscript"), "aria-pressed": activeState.subscript, style: activeButtonStyle(activeState.subscript), children: ["X", _jsx("sub", { children: "2" })] }), _jsxs("button", { title: "Superscript", onClick: () => toggleInlineScript("superscript"), "aria-pressed": activeState.superscript, style: activeButtonStyle(activeState.superscript), children: ["X", _jsx("sup", { children: "2" })] }), [
4971
+ {
4972
+ key: "check",
4973
+ label: "☐",
4974
+ title: "Checklist",
4975
+ active: activeState.checklist,
4976
+ action: () => applyChecklist(false, true),
4977
+ options: [["check:plain", "☐ Checklist"], ["check:strike", "☑ Checked + strike"]],
4978
+ },
4979
+ {
4980
+ key: "bullet",
4981
+ label: "•≡",
4982
+ title: "Bulleted list",
4983
+ active: activeState.unorderedList,
4984
+ action: () => applyListStyle("bullet:disc"),
4985
+ options: [["bullet:disc", "• Disc"], ["bullet:circle", "○ Circle"], ["bullet:square", "▪ Square"]],
4986
+ },
4987
+ {
4988
+ key: "ordered",
4989
+ label: "1≡",
4990
+ title: "Numbered list",
4991
+ active: activeState.orderedList,
4992
+ action: () => applyListStyle("ordered:decimal"),
4993
+ 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."]],
4994
+ },
4995
+ ].map((control) => (_jsxs("span", { style: { display: "inline-flex", height: 32 }, children: [_jsx("button", { type: "button", title: control.title, onPointerDown: preserveEditorSelection, onClick: control.action, "aria-pressed": control.active, style: activeButtonStyle(control.active, { padding: "0 9px", borderRadius: "6px 0 0 6px" }), children: control.label }), _jsxs("select", { defaultValue: "", "aria-label": `${control.title} styles`, title: `${control.title} styles`, onPointerDown: preserveEditorSelection, onMouseDown: preserveEditorSelection, onChange: (event) => {
4996
+ const selected = event.currentTarget.value;
4997
+ if (selected === "check:plain")
4998
+ applyChecklist(false);
4999
+ else if (selected === "check:strike")
5000
+ applyChecklist(true);
5001
+ else if (selected)
5002
+ applyListStyle(selected);
5003
+ event.currentTarget.value = "";
5004
+ }, style: {
5005
+ width: 28,
5006
+ height: 32,
5007
+ padding: 0,
5008
+ border: "1px solid var(--srte-input-border)",
5009
+ borderLeft: 0,
5010
+ borderRadius: "0 6px 6px 0",
5011
+ background: "var(--srte-input-bg)",
5012
+ color: "var(--srte-input-text)",
5013
+ }, 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", title: "Blockquote", onPointerDown: preserveEditorSelection, onClick: toggleBlockquote, "aria-pressed": activeState.blockquote, style: activeButtonStyle(activeState.blockquote), children: "\u275D" }), _jsx("button", { title: "Special characters", onClick: () => setShowSpecialChars(true), style: {
3950
5014
  height: 32,
3951
5015
  minWidth: 32,
3952
5016
  padding: "0 8px",
@@ -3954,7 +5018,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3954
5018
  borderRadius: 6,
3955
5019
  background: "var(--srte-input-bg)",
3956
5020
  color: "var(--srte-input-text)",
3957
- }, children: "\u03A9" }), _jsx("button", { title: "Code block", onClick: () => exec("formatBlock", "<pre>"), "aria-pressed": activeState.codeBlock, style: activeButtonStyle(activeState.codeBlock, {
5021
+ }, children: "\u03A9" }), _jsx("button", { type: "button", title: "Code block", onPointerDown: preserveEditorSelection, onClick: toggleCodeBlock, "aria-pressed": activeState.codeBlock, style: activeButtonStyle(activeState.codeBlock, {
3958
5022
  minWidth: 36,
3959
5023
  fontFamily: "ui-monospace, SFMono-Regular, Menlo",
3960
5024
  }), children: "< />" }), formula && (_jsx("button", { title: "Insert formula", onClick: () => setShowFormulaDialog(true), style: {
@@ -3965,21 +5029,18 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
3965
5029
  borderRadius: 6,
3966
5030
  background: "var(--srte-input-bg)",
3967
5031
  color: "var(--srte-input-text)",
3968
- }, children: "\u2211" })), _jsx("button", { title: "Insert link", onClick: insertLink, style: {
3969
- height: 32,
3970
- padding: "0 10px",
3971
- border: "1px solid var(--srte-input-border)",
3972
- borderRadius: 6,
3973
- background: "var(--srte-input-bg)",
3974
- color: "var(--srte-input-text)",
3975
- }, children: "Link" }), _jsx("button", { title: "Remove link", onClick: () => exec("unlink"), style: {
5032
+ }, children: "\u2211" })), _jsx("button", { type: "button", title: "Insert link", "aria-label": "Insert or edit link", "aria-pressed": activeState.link, onPointerDown: preserveEditorSelection, onClick: () => openLinkEditor(), style: activeButtonStyle(activeState.link, { minWidth: 34, fontSize: 17 }), children: _jsx("span", { "aria-hidden": "true", children: "\u2197" }) }), _jsx("button", { type: "button", title: "Remove link", "aria-label": "Remove link", disabled: !activeState.link, onPointerDown: preserveEditorSelection, onClick: () => exec("unlink"), style: {
3976
5033
  height: 32,
3977
- padding: "0 10px",
5034
+ minWidth: 34,
5035
+ padding: "0 8px",
3978
5036
  border: "1px solid var(--srte-input-border)",
3979
5037
  borderRadius: 6,
3980
5038
  background: "var(--srte-input-bg)",
3981
5039
  color: "var(--srte-input-text)",
3982
- }, children: "Unlink" }), media && (_jsxs(_Fragment, { children: [_jsx("button", { title: "Insert image", onClick: insertImage, style: {
5040
+ cursor: activeState.link ? "pointer" : "not-allowed",
5041
+ opacity: activeState.link ? 1 : 0.45,
5042
+ fontSize: 16,
5043
+ }, children: _jsx("span", { "aria-hidden": "true", children: "\u2197\u0338" }) }), media && (_jsxs(_Fragment, { children: [_jsx("button", { title: "Insert image", onClick: insertImage, style: {
3983
5044
  height: 32,
3984
5045
  padding: "0 10px",
3985
5046
  border: "1px solid var(--srte-input-border)",
@@ -4483,8 +5544,14 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4483
5544
  }, onMouseMove: (e) => {
4484
5545
  if (draggedBlockRef.current)
4485
5546
  return;
4486
- updateDragHandleForTarget(getMovableElementFromNode(e.target));
4487
- }, onMouseLeave: () => {
5547
+ updateDragHandleForTarget(isNode(e.target) ? getMovableElementFromNode(e.target) : null);
5548
+ }, onMouseOver: (e) => {
5549
+ if (draggedBlockRef.current)
5550
+ return;
5551
+ updateDragHandleForTarget(isNode(e.target) ? getMovableElementFromNode(e.target) : null);
5552
+ }, onMouseLeave: (e) => {
5553
+ if (closestFromTarget(e.relatedTarget, "[data-srte-drag-handle]"))
5554
+ return;
4488
5555
  if (!draggedBlockRef.current)
4489
5556
  scheduleDragHandleHide();
4490
5557
  }, onPaste: (e) => {
@@ -4580,13 +5647,31 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4580
5647
  }
4581
5648
  }, onClick: (e) => {
4582
5649
  const t = e.target;
5650
+ if (t.dataset.srteCheck === "true") {
5651
+ const item = t.closest("li");
5652
+ const list = item?.closest('[data-srte-checklist="true"]');
5653
+ if (item && list) {
5654
+ pushEditorHistory();
5655
+ const checked = item.dataset.checked !== "true";
5656
+ item.dataset.checked = checked ? "true" : "false";
5657
+ t.textContent = checked ? "☑" : "☐";
5658
+ t.setAttribute("aria-label", checked ? "Mark incomplete" : "Mark complete");
5659
+ item.style.textDecoration =
5660
+ list.dataset.srteChecklistStrike === "true" && checked ? "line-through" : "";
5661
+ handleInput();
5662
+ return;
5663
+ }
5664
+ }
4583
5665
  const anchor = t?.closest("a");
4584
5666
  if (anchor && editableRef.current?.contains(anchor)) {
4585
5667
  e.preventDefault();
4586
5668
  e.stopPropagation();
4587
- openEditorLink(anchor);
5669
+ openLinkEditor(anchor);
5670
+ setTableMenu(null);
5671
+ setImageMenu(null);
4588
5672
  return;
4589
5673
  }
5674
+ setLinkMenu(null);
4590
5675
  if (t && t.tagName === "IMG") {
4591
5676
  setSelectedImage(t);
4592
5677
  scheduleImageOverlay();
@@ -4638,6 +5723,11 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4638
5723
  }
4639
5724
  updateActiveState();
4640
5725
  }, onKeyDown: (e) => {
5726
+ if ((e.metaKey || e.ctrlKey) && String(e.key).toLowerCase() === "k") {
5727
+ e.preventDefault();
5728
+ openLinkEditor();
5729
+ return;
5730
+ }
4641
5731
  if ((e.metaKey || e.ctrlKey) && String(e.key).toLowerCase() === "z") {
4642
5732
  const restored = restoreEditorHistory(e.shiftKey ? "redo" : "undo");
4643
5733
  if (restored) {
@@ -4789,7 +5879,7 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4789
5879
  setTableMenu(null);
4790
5880
  setImageMenu(null);
4791
5881
  }
4792
- } }), dragHandle && !readOnly && (_jsx("button", { type: "button", draggable: true, title: "Drag block", "aria-label": "Drag block", onMouseEnter: () => {
5882
+ } }), dragHandle && !readOnly && (_jsx("button", { type: "button", draggable: true, "data-srte-drag-handle": "true", title: "Drag block", "aria-label": "Drag block", onMouseEnter: () => {
4793
5883
  if (dragHandleHideTimerRef.current != null) {
4794
5884
  window.clearTimeout(dragHandleHideTimerRef.current);
4795
5885
  dragHandleHideTimerRef.current = null;
@@ -4926,7 +6016,10 @@ export function ClassicEditor({ value, onChange, placeholder = "Type here…", m
4926
6016
  borderRadius: 2,
4927
6017
  cursor: "ew-resize",
4928
6018
  pointerEvents: "auto",
4929
- } })] }))] }), tableMenu && (_jsx("div", { style: {
6019
+ } })] }))] }), 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 ? () => {
6020
+ openEditorLink(linkMenu.anchor);
6021
+ setLinkMenu(null);
6022
+ } : undefined, onRemove: linkMenu.anchor ? () => removeAnchorLink(linkMenu.anchor) : undefined, onCancel: () => setLinkMenu(null) })), tableMenu && (_jsx("div", { style: {
4930
6023
  position: "fixed",
4931
6024
  inset: 0,
4932
6025
  zIndex: 60,