@stll/folio-core 0.54.0 → 0.54.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/ai-edits/apply.d.ts +1 -1
  2. package/dist/ai-edits/apply.js +43 -31
  3. package/dist/ai-edits/headless.d.ts +1 -2
  4. package/dist/ai-edits/headless.js +12 -27
  5. package/dist/ai-edits/read.d.ts +1 -0
  6. package/dist/ai-edits/read.js +7 -2
  7. package/dist/ai-edits/table-mutation-plan.d.ts +2 -1
  8. package/dist/ai-edits/table-mutation-plan.js +39 -1
  9. package/dist/ai-suggestions/apply.d.ts +1 -1
  10. package/dist/ai-suggestions/apply.js +3 -2
  11. package/dist/compare/compare.js +2 -1
  12. package/dist/docx/bookmarkIds.d.ts +11 -0
  13. package/dist/docx/bookmarkIds.js +33 -0
  14. package/dist/docx/documentParser.d.ts +14 -1
  15. package/dist/docx/documentParser.js +17 -4
  16. package/dist/docx/drawingIdNormalization.js +23 -8
  17. package/dist/docx/ensureParaIds.d.ts +5 -0
  18. package/dist/docx/ensureParaIds.js +10 -2
  19. package/dist/docx/listNumberingInstances.js +11 -9
  20. package/dist/docx/noteIds.d.ts +4 -0
  21. package/dist/docx/noteIds.js +13 -0
  22. package/dist/docx/numberingIds.d.ts +14 -0
  23. package/dist/docx/numberingIds.js +16 -0
  24. package/dist/docx/numericIdAllocator.d.ts +12 -0
  25. package/dist/docx/numericIdAllocator.js +33 -0
  26. package/dist/docx/numericIdNormalization.d.ts +16 -0
  27. package/dist/docx/numericIdNormalization.js +174 -0
  28. package/dist/docx/paragraphPropertySource.js +1 -1
  29. package/dist/docx/paragraphTextBoxEnrichment.js +2 -1
  30. package/dist/docx/parser.js +17 -5
  31. package/dist/docx/rasterMime.d.ts +64 -0
  32. package/dist/docx/rasterMime.js +116 -0
  33. package/dist/docx/replyToComment.js +13 -6
  34. package/dist/docx/rezip.js +14 -7
  35. package/dist/docx/selectiveSave.js +10 -0
  36. package/dist/docx/serializer/partNamespaces.js +4 -2
  37. package/dist/docx/serializer/runSerializer.js +5 -4
  38. package/dist/docx/serializer/trackedChangeAttributes.js +7 -1
  39. package/dist/docx/server/build.js +14 -3
  40. package/dist/docx/server/createBilingualDocument.js +10 -9
  41. package/dist/docx/streamingXmlParser.d.ts +10 -1
  42. package/dist/docx/streamingXmlParser.js +88 -19
  43. package/dist/docx/unzip.d.ts +3 -1
  44. package/dist/docx/unzip.js +36 -16
  45. package/dist/docx/verticalMergeProjection.d.ts +17 -0
  46. package/dist/docx/verticalMergeProjection.js +120 -0
  47. package/dist/layout-bridge/convert/fixedTableColumnWidths.d.ts +6 -0
  48. package/dist/layout-bridge/convert/fixedTableColumnWidths.js +23 -0
  49. package/dist/layout-bridge/convert/tableConversion.js +4 -1
  50. package/dist/layout-painter/index.js +2 -5
  51. package/dist/paged-layout/editorScrollRoot.d.ts +15 -0
  52. package/dist/paged-layout/editorScrollRoot.js +72 -0
  53. package/dist/paged-layout/scrollToPmPosition.js +8 -22
  54. package/dist/prosemirror/commands/clearParagraphIndent.d.ts +6 -0
  55. package/dist/prosemirror/commands/clearParagraphIndent.js +25 -0
  56. package/dist/prosemirror/commands/comments.js +6 -1
  57. package/dist/prosemirror/commands/propertyChangeScope.js +4 -1
  58. package/dist/prosemirror/commands/resolveAllTableChanges.js +18 -24
  59. package/dist/prosemirror/commands/resolveParagraphProperties.js +13 -2
  60. package/dist/prosemirror/commands/tableCellMergeResolution.d.ts +8 -9
  61. package/dist/prosemirror/commands/tableCellMergeResolution.js +28 -25
  62. package/dist/prosemirror/commands/tableMergeFoldDecisions.d.ts +61 -0
  63. package/dist/prosemirror/commands/tableMergeFoldDecisions.js +79 -0
  64. package/dist/prosemirror/commentIdAllocator.d.ts +15 -22
  65. package/dist/prosemirror/commentIdAllocator.js +50 -25
  66. package/dist/prosemirror/conversion/toProseDoc.d.ts +1 -6
  67. package/dist/prosemirror/conversion/toProseDoc.js +2 -117
  68. package/dist/prosemirror/extensions/core/HistoryExtension.js +97 -5
  69. package/dist/prosemirror/extensions/core/ParagraphExtension.js +3 -1
  70. package/dist/prosemirror/extensions/features/BaseKeymapExtension.js +22 -28
  71. package/dist/prosemirror/extensions/nodes/ShapeExtension.js +8 -6
  72. package/dist/prosemirror/listNumbering.js +14 -14
  73. package/dist/prosemirror/listRendering.d.ts +4 -2
  74. package/dist/prosemirror/listRendering.js +3 -4
  75. package/dist/prosemirror/paragraphPropertyCarry.js +2 -5
  76. package/dist/prosemirror/plugins/revisionIds.d.ts +23 -19
  77. package/dist/prosemirror/plugins/revisionIds.js +110 -29
  78. package/dist/prosemirror/plugins/suggestionMode.d.ts +2 -2
  79. package/dist/prosemirror/plugins/suggestionMode.js +197 -45
  80. package/dist/prosemirror/plugins/templateDirectives.d.ts +6 -0
  81. package/dist/prosemirror/plugins/templateDirectives.js +21 -7
  82. package/dist/prosemirror/plugins/templatePreviewValues.js +14 -21
  83. package/dist/prosemirror/storyListNumbering.js +11 -9
  84. package/dist/prosemirror/textInput.d.ts +6 -1
  85. package/dist/prosemirror/textInput.js +9 -2
  86. package/dist/prosemirror/utils/visualLineNavigation.js +6 -8
  87. package/dist/utils/mergeDocumentContent.d.ts +2 -2
  88. package/dist/utils/mergeDocumentContent.js +11 -8
  89. package/package.json +3 -3
@@ -1,4 +1,5 @@
1
1
  import { expectParagraphAttrs, expectTrackedChangeMarkAttrs } from "../attrs/index.js";
2
+ import { clearIndentOnBackspace } from "../commands/clearParagraphIndent.js";
2
3
  import { paragraphPropertiesSnapshot } from "../commands/propertyChangeScope.js";
3
4
  import { paragraphEndsItsContainer } from "../containerFinalParagraph.js";
4
5
  import { encloseWholeControls } from "../contentControlRevisions.js";
@@ -10,14 +11,16 @@ import { rebaseParagraphRuns } from "../rebaseParagraphRunFormatting.js";
10
11
  import { RUN_FORMATTING_MARK_NAMES } from "../runFormattingMarkNames.js";
11
12
  import { paragraphRunStyleContext } from "../runStyleFormatting.js";
12
13
  import { cellPasteRange, pasteTableCells } from "../tableCellPaste.js";
13
- import { handleEditorBeforeInput } from "../textInput.js";
14
+ import { afterNativeCompositionFlush, handleEditorBeforeInput } from "../textInput.js";
14
15
  import { canCarryTrackedRunMark } from "../trackedRunInlineAtoms.js";
15
16
  import { getDocumentNumbering } from "./documentNumbering.js";
16
- import { getDocumentStyleResolver } from "./documentStyleState.js";
17
+ import { documentStylesKey, getDocumentStyleResolver } from "./documentStyleState.js";
17
18
  import { mintRevisionId, seedRevisionIdsFromDoc } from "./revisionIds.js";
18
19
  import { Slice } from "prosemirror-model";
20
+ import { CellSelection } from "prosemirror-tables";
19
21
  import { Mapping, ReplaceStep } from "prosemirror-transform";
20
- import { AllSelection, Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state";
22
+ import { AllSelection, EditorState, Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state";
23
+ import { joinBackward, joinForward, selectNodeBackward, selectNodeForward } from "prosemirror-commands";
21
24
  import { isHistoryTransaction } from "prosemirror-history";
22
25
  import { undoInputRule } from "prosemirror-inputrules";
23
26
  //#region src/prosemirror/plugins/suggestionMode.ts
@@ -241,10 +244,44 @@ function markRangeAsInserted(tr, doc, from, to, insertionType, deletionType, att
241
244
  function handleSuggestionPaste(view, slice, pluginState) {
242
245
  const { selection } = view.state;
243
246
  const selectsAll = selection instanceof AllSelection;
244
- if (!(selection instanceof TextSelection || selectsAll) || selection.empty) return false;
247
+ if (!(selection instanceof TextSelection || selectsAll)) return false;
245
248
  const insertionType = view.state.schema.marks["insertion"];
246
249
  const deletionType = view.state.schema.marks["deletion"];
247
250
  if (!insertionType || !deletionType) return false;
251
+ if (selection.empty) {
252
+ const { $from } = selection;
253
+ const closedTable = slice.openStart === 0 && slice.openEnd === 0 && slice.content.firstChild?.type.name === "table" && slice.content.lastChild?.type.name === "table";
254
+ const prefix = $from.parent.content.cut(0, $from.parentOffset);
255
+ let deletedPrefix = prefix.size > 0;
256
+ prefix.forEach((node) => {
257
+ if (!node.marks.some((mark) => mark.type === deletionType)) deletedPrefix = false;
258
+ });
259
+ if (!closedTable || $from.parent.type.name !== "paragraph" || !deletedPrefix) return false;
260
+ const suffix = $from.parent.content.cut($from.parentOffset);
261
+ let deletedSuffix = true;
262
+ suffix.forEach((node) => {
263
+ if (!node.marks.some((mark) => mark.type === deletionType)) deletedSuffix = false;
264
+ });
265
+ const tr = view.state.tr.setMeta(SUGGESTION_META, true).setMeta("paste", true);
266
+ const at = $from.before();
267
+ tr.replaceRange(at, at, slice);
268
+ const revision = makeMarkAttrs(pluginState);
269
+ const end = tr.mapping.map(at, 1);
270
+ enclosePastedRunRevisions({
271
+ tr,
272
+ from: at,
273
+ to: end,
274
+ revision
275
+ });
276
+ markRangeAsInserted(tr, tr.doc, at, end, insertionType, deletionType, revision);
277
+ if (deletedSuffix && $from.parent.attrs["pPrMark"] == null && !paragraphEndsItsContainer($from.doc.resolve($from.before()), "paragraph")) tr.setNodeAttribute(tr.mapping.map($from.before()), "pPrMark", {
278
+ kind: "del",
279
+ info: makeParagraphMarkInfo(pluginState)
280
+ });
281
+ tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map(selection.from)));
282
+ view.dispatch(tr.scrollIntoView());
283
+ return true;
284
+ }
248
285
  if (selectsAll && (slice.openStart > 0 || slice.openEnd > 0)) slice = new Slice(view.state.tr.replaceSelection(slice).doc.content, 0, 0);
249
286
  const { from, to } = selectsAll ? {
250
287
  from: Selection.atStart(view.state.doc).from,
@@ -372,16 +409,54 @@ function handleSuggestionTableCellPaste(view, slice, pluginState) {
372
409
  const insertFrom = tr.mapping.slice(mapFrom).map(to);
373
410
  const sizeBefore = tr.doc.content.size;
374
411
  tr.replaceRange(insertFrom, insertFrom, content);
375
- const insertTo = insertFrom + (tr.doc.content.size - sizeBefore);
412
+ let insertTo = insertFrom + (tr.doc.content.size - sizeBefore);
413
+ let pastedFrom = insertFrom;
414
+ const finalParagraph = cell.lastChild;
415
+ const $pasteFrom = tr.doc.resolve(pastedFrom);
416
+ const $pasteTo = tr.doc.resolve(insertTo);
417
+ if (content.openStart > 0 && content.openEnd > 0 && finalParagraph?.type.name === "paragraph" && $pasteFrom.parent.type.name === "paragraph" && $pasteTo.parent.type.name === "paragraph" && $pasteFrom.before() !== $pasteTo.before()) {
418
+ tr.setNodeAttribute($pasteFrom.before(), "pPrMark", {
419
+ kind: "ins",
420
+ info: {
421
+ id: revision.revisionId,
422
+ author: revision.author,
423
+ date: revision.date
424
+ }
425
+ });
426
+ tr.setNodeAttribute($pasteTo.before(), "pPrMark", finalParagraph.attrs["pPrMark"]);
427
+ recordReplacedParagraphProperties({
428
+ tr,
429
+ position: $pasteTo.before(),
430
+ replaced: finalParagraph,
431
+ revision: {
432
+ id: revision.revisionId,
433
+ author: revision.author,
434
+ date: revision.date
435
+ }
436
+ });
437
+ }
438
+ const emptyInsertions = [];
439
+ tr.doc.nodeAt(cellPos)?.forEach((node, offset) => {
440
+ const pos = cellPos + 1 + offset;
441
+ if (pos < pastedFrom && node.type.name === "paragraph" && node.content.size === 0 && isCurrentAuthorParagraphInsertion(node.attrs["pPrMark"], pluginState.author)) emptyInsertions.push({
442
+ from: pos,
443
+ to: pos + node.nodeSize
444
+ });
445
+ });
446
+ const retraction = tr.mapping.maps.length;
447
+ for (const range of emptyInsertions.toReversed()) tr.delete(range.from, range.to);
448
+ const retracted = tr.mapping.slice(retraction);
449
+ pastedFrom = retracted.map(pastedFrom);
450
+ insertTo = retracted.map(insertTo);
376
451
  enclosePastedRunRevisions({
377
452
  tr,
378
- from: insertFrom,
453
+ from: pastedFrom,
379
454
  to: insertTo,
380
455
  revision
381
456
  });
382
- markRangeAsInserted(tr, tr.doc, insertFrom, insertTo, insertionType, deletionType, revision);
383
- tr.doc.nodesBetween(insertFrom, insertTo, (node, pos) => {
384
- if (node.type.name === "paragraph" && pos >= insertFrom && pos + node.nodeSize <= insertTo && node.attrs["pPrMark"] == null) tr.setNodeAttribute(pos, "pPrMark", {
457
+ markRangeAsInserted(tr, tr.doc, pastedFrom, insertTo, insertionType, deletionType, revision);
458
+ tr.doc.nodesBetween(pastedFrom, insertTo, (node, pos) => {
459
+ if (node.type.name === "paragraph" && pos >= pastedFrom && pos + node.nodeSize <= insertTo && node.attrs["pPrMark"] == null) tr.setNodeAttribute(pos, "pPrMark", {
385
460
  kind: "ins",
386
461
  info: {
387
462
  id: revision.revisionId,
@@ -407,11 +482,7 @@ function rotateIntoFinalParagraph(state, tr, replacedPos, attrs) {
407
482
  const replaced = tr.doc.nodeAt(replacedPos);
408
483
  const first = replaced ? tr.doc.nodeAt(replacedPos + replaced.nodeSize) : null;
409
484
  if (!replaced || !first) return replacedPos;
410
- const replacedAttrs = expectParagraphAttrs(replaced);
411
- const previousFormatting = {
412
- ...paragraphPropertiesSnapshot(replaced),
413
- numPr: replacedAttrs.numPr ?? null
414
- };
485
+ const previousFormatting = paragraphPropertiesSnapshot(replaced);
415
486
  const { _sectionProperties: sectionProperties } = replaced.attrs;
416
487
  tr.join(replacedPos + replaced.nodeSize);
417
488
  tr.setNodeMarkup(replacedPos, void 0, first.attrs);
@@ -537,15 +608,38 @@ function suggestRangeDeletion(state, tr, from, to) {
537
608
  * prior author's revision must not be silently overwritten.
538
609
  */
539
610
  function handleSuggestionEnter(view, pluginState) {
540
- const { $from } = view.state.selection;
541
- if ($from.parent.type.name !== "paragraph") return false;
611
+ const state = view.state;
612
+ if (state.selection.$from.parent.type.name !== "paragraph") return false;
613
+ const replacement = { tr: null };
614
+ let splitState = state;
615
+ if (state.selection instanceof TextSelection && !state.selection.empty) {
616
+ if (!handleSuggestionDelete(state, (tr) => {
617
+ replacement.tr = tr;
618
+ const styles = documentStylesKey.get(state);
619
+ const plugins = [];
620
+ if (styles) {
621
+ const spec = { ...styles.spec };
622
+ delete spec.appendTransaction;
623
+ delete spec.filterTransaction;
624
+ plugins.push(new Plugin(spec));
625
+ }
626
+ splitState = state.reconfigure({ plugins }).apply(tr);
627
+ }, "forward")) return false;
628
+ }
629
+ const { $from } = splitState.selection;
542
630
  const sourcePos = $from.before();
543
631
  const sourceAttrs = $from.parent.attrs;
544
632
  const captured = { tr: null };
545
- if (!splitBlockClearBorders(view.state, (tr) => {
633
+ if (!splitBlockClearBorders(splitState, (tr) => {
546
634
  captured.tr = tr;
547
635
  }, view) || !captured.tr) return false;
548
- const tr = captured.tr;
636
+ const tr = replacement.tr ?? captured.tr;
637
+ if (replacement.tr) {
638
+ for (const step of captured.tr.steps) tr.step(step);
639
+ tr.setSelection(captured.tr.selection.getBookmark().resolve(tr.doc));
640
+ tr.setStoredMarks(captured.tr.storedMarks);
641
+ }
642
+ tr.setMeta(SUGGESTION_META, true);
549
643
  if (sourceAttrs["pPrMark"] == null) {
550
644
  if (tr.doc.nodeAt(sourcePos)?.type.name === "paragraph") {
551
645
  const markInfo = {
@@ -565,6 +659,24 @@ function handleSuggestionEnter(view, pluginState) {
565
659
  view.dispatch(tr.scrollIntoView());
566
660
  return true;
567
661
  }
662
+ /** Find the adjacent visible unit without consuming a bookmark range marker. */
663
+ function caretDeleteTarget(state, direction) {
664
+ const { $from } = state.selection;
665
+ const backward = direction === "backward";
666
+ let from = backward ? $from.pos - 1 : $from.pos;
667
+ let to = backward ? $from.pos : $from.pos + 1;
668
+ while (from >= $from.start() && to <= $from.end()) {
669
+ const adjacent = state.doc.resolve(from).nodeAfter;
670
+ if (adjacent?.type.name !== "bookmarkBoundary") return {
671
+ type: "inlineUnit",
672
+ from,
673
+ to
674
+ };
675
+ from += backward ? -adjacent.nodeSize : adjacent.nodeSize;
676
+ to += backward ? -adjacent.nodeSize : adjacent.nodeSize;
677
+ }
678
+ return { type: "paragraphEdge" };
679
+ }
568
680
  /**
569
681
  * Detect a caret-at-paragraph-boundary scenario where Backspace/Delete should
570
682
  * record a tracked paragraph-mark deletion instead of joining paragraphs.
@@ -576,8 +688,8 @@ function handleSuggestionEnter(view, pluginState) {
576
688
  * - Delete-at-paragraph-end → current paragraph.
577
689
  *
578
690
  * Returns `null` when there is no adjacent sibling paragraph (the join would
579
- * not produce paragraph-mark merging — e.g. doc start, doc end, or sibling
580
- * is a table). The default merge path then runs unchanged.
691
+ * not produce paragraph-mark merging, e.g. doc start, doc end, or a table).
692
+ * Bookmark markers at an edge do not hide the adjacent paragraph break.
581
693
  */
582
694
  function paragraphBoundaryTarget(state, direction) {
583
695
  const { $from, empty } = state.selection;
@@ -585,14 +697,13 @@ function paragraphBoundaryTarget(state, direction) {
585
697
  if ($from.parent.type.name !== "paragraph") return null;
586
698
  const paragraphStart = $from.before();
587
699
  const paragraphEnd = $from.after();
700
+ if (caretDeleteTarget(state, direction).type !== "paragraphEdge") return null;
588
701
  if (direction === "backward") {
589
- if ($from.parentOffset !== 0) return null;
590
702
  if (paragraphStart === 0) return null;
591
703
  const prev = state.doc.resolve(paragraphStart).nodeBefore;
592
704
  if (!prev || prev.type.name !== "paragraph") return null;
593
705
  return paragraphStart - prev.nodeSize;
594
706
  }
595
- if ($from.parentOffset !== $from.parent.content.size) return null;
596
707
  if (paragraphEnd >= state.doc.content.size) return null;
597
708
  const next = state.doc.resolve(paragraphEnd).nodeAfter;
598
709
  if (!next || next.type.name !== "paragraph") return null;
@@ -616,7 +727,13 @@ function applyPPrDel(view, targetParagraphPos, pluginState) {
616
727
  const joined = view.state.doc.nodeAt(joinPos);
617
728
  try {
618
729
  tr.join(joinPos);
619
- tr.setNodeAttribute(targetParagraphPos, "pPrMark", null);
730
+ tr.setNodeAttribute(targetParagraphPos, "pPrMark", joined?.type === targetNode.type ? expectParagraphAttrs(joined).pPrMark : null);
731
+ if (joined?.type === targetNode.type) recordReplacedParagraphProperties({
732
+ tr,
733
+ position: targetParagraphPos,
734
+ replaced: joined,
735
+ revision: existingMark.info
736
+ });
620
737
  const styleResolver = getDocumentStyleResolver(view.state);
621
738
  if (styleResolver && joined?.type === targetNode.type && joined.content.size > 0) rebaseParagraphRuns({
622
739
  previousContext: paragraphRunStyleContext(joined, styleResolver),
@@ -675,15 +792,28 @@ function handleSuggestionDelete(state, dispatch, direction) {
675
792
  to: rangeTo.pos
676
793
  }).sort((a, b) => b.from - a.from);
677
794
  for (const { from, to } of ranges) markRangeAsDeleted(tr, state.doc, from, to, insertionType, deletionType, pluginState, state);
678
- const cursorPos = tr.mapping.map($to.pos);
679
- tr.setSelection(TextSelection.near(tr.doc.resolve(cursorPos)));
795
+ if (state.selection instanceof CellSelection) tr.setSelection(state.selection.map(tr.doc, tr.mapping));
796
+ else {
797
+ const cursorPos = tr.mapping.map($to.pos);
798
+ tr.setSelection(TextSelection.near(tr.doc.resolve(cursorPos)));
799
+ }
680
800
  dispatch(tr.scrollIntoView());
681
801
  return true;
682
802
  }
683
803
  const isBackward = direction === "backward";
684
- const deletePos = isBackward ? $from.pos - 1 : $from.pos;
685
- const deleteEnd = isBackward ? $from.pos : $from.pos + 1;
686
- if (deletePos < 0 || deleteEnd > state.doc.content.size) return true;
804
+ const target = caretDeleteTarget(state, direction);
805
+ if (target.type === "paragraphEdge") {
806
+ const edge = isBackward ? $from.start() : $from.end();
807
+ if ($from.pos === edge) return false;
808
+ const virtual = EditorState.create({
809
+ doc: state.doc,
810
+ selection: TextSelection.create(state.doc, edge)
811
+ });
812
+ if ((isBackward ? joinBackward : joinForward)(virtual, dispatch)) return true;
813
+ return (isBackward ? selectNodeBackward : selectNodeForward)(virtual, dispatch);
814
+ }
815
+ const deletePos = target.from;
816
+ const deleteEnd = target.to;
687
817
  const noteRange = expandNoteReferenceDeletionRange(state.doc, deletePos, deleteEnd);
688
818
  const rangeFrom = noteRange?.from ?? deletePos;
689
819
  const rangeTo = noteRange?.to ?? deleteEnd;
@@ -705,19 +835,7 @@ function handleSuggestionDelete(state, dispatch, direction) {
705
835
  }
706
836
  /** Apply the editor's selected-content deletion path for a host cut action. */
707
837
  const deleteSelectionAsSuggestion = (state, dispatch) => state.selection.empty ? false : handleSuggestionDelete(state, dispatch, "forward");
708
- /**
709
- * Mark the text committed by an IME composition as a tracked insertion.
710
- *
711
- * Invoked from `compositionend` (deferred to a microtask) rather than from the
712
- * `appendTransaction` catch-all. ProseMirror commits composed text as part of
713
- * its own composition-end flush; adding the insertion mark *synchronously*
714
- * during that flush re-wraps the very text node the IME just finalized, which
715
- * corrupts CJK (Japanese / Chinese / Korean) input — characters duplicate or
716
- * garble. Running one tick later, after the composition has settled, wraps the
717
- * committed range safely. `markRangeAsInserted` skips nodes that already carry a
718
- * tracked-change mark, so this is idempotent. eigenpal/docx-editor#938.
719
- */
720
- function markComposedAsInsertion(view, from, pluginState, replaced) {
838
+ function markComposedAsInsertion({ view, from, pluginState, replaced, compositionId }) {
721
839
  const insertionType = view.state.schema.marks["insertion"];
722
840
  const deletionType = view.state.schema.marks["deletion"];
723
841
  if (!insertionType || !deletionType) return;
@@ -725,9 +843,16 @@ function markComposedAsInsertion(view, from, pluginState, replaced) {
725
843
  if (to <= from && !replaced) return;
726
844
  const tr = view.state.tr;
727
845
  tr.setMeta(SUGGESTION_META, true);
846
+ if (compositionId !== null) tr.setMeta("composition", compositionId);
728
847
  if (replaced) {
729
- tr.insert(from, replaced.content);
730
- markRangeAsDeleted(tr, tr.doc, from, from + replaced.content.size, insertionType, deletionType, pluginState);
848
+ tr.replaceRange(from, from, replaced);
849
+ markRangeAsDeleted(tr, tr.doc, from, tr.mapping.map(from, 1), insertionType, deletionType, pluginState);
850
+ const restoredEnd = tr.doc.resolve(tr.mapping.map(from, 1));
851
+ const restoredStart = tr.doc.resolve(from);
852
+ if (restoredStart.parent.type.name === "paragraph" && restoredEnd.parent.type.name === "paragraph" && restoredStart.parent !== restoredEnd.parent) {
853
+ const mark = expectParagraphAttrs(restoredStart.parent).pPrMark;
854
+ if (mark?.kind === "del") carryIntoParagraphLeft(view.state, tr, view.state.doc.resolve(from).parent, restoredEnd.before(), mark.info);
855
+ }
731
856
  }
732
857
  const insertionFrom = tr.mapping.map(from, 1);
733
858
  const insertionTo = tr.mapping.map(to, 1);
@@ -777,6 +902,7 @@ function createSuggestionModePlugin(initialActive = false, author = "User") {
777
902
  let composing = false;
778
903
  let compositionFrom = null;
779
904
  let compositionReplaced = null;
905
+ let compositionId = null;
780
906
  return new Plugin({
781
907
  key: suggestionModeKey,
782
908
  state: {
@@ -788,6 +914,8 @@ function createSuggestionModePlugin(initialActive = false, author = "User") {
788
914
  };
789
915
  },
790
916
  apply(tr, state) {
917
+ const nativeCompositionId = tr.getMeta("composition");
918
+ if (composing && tr.docChanged && typeof nativeCompositionId === "number") compositionId = nativeCompositionId;
791
919
  const meta = tr.getMeta(suggestionModeKey);
792
920
  if (meta) return {
793
921
  ...state,
@@ -813,6 +941,7 @@ function createSuggestionModePlugin(initialActive = false, author = "User") {
813
941
  if (!suggestionModeKey.getState(view.state)?.active) return false;
814
942
  if (composing) return false;
815
943
  composing = true;
944
+ compositionId = null;
816
945
  const { from, to } = view.state.selection;
817
946
  compositionFrom = from;
818
947
  compositionReplaced = from === to ? null : view.state.doc.slice(from, to);
@@ -826,14 +955,23 @@ function createSuggestionModePlugin(initialActive = false, author = "User") {
826
955
  compositionReplaced = null;
827
956
  if (!pluginState?.active || from == null) {
828
957
  composing = false;
958
+ compositionId = null;
829
959
  return false;
830
960
  }
831
- queueMicrotask(() => {
961
+ afterNativeCompositionFlush(() => {
832
962
  try {
833
963
  const current = suggestionModeKey.getState(view.state);
834
- if (current?.active) markComposedAsInsertion(view, from, current, replaced);
964
+ const nativeCompositionId = compositionId;
965
+ if (current?.active) markComposedAsInsertion({
966
+ view,
967
+ from,
968
+ pluginState: current,
969
+ replaced,
970
+ compositionId: nativeCompositionId
971
+ });
835
972
  } finally {
836
973
  composing = false;
974
+ compositionId = null;
837
975
  }
838
976
  });
839
977
  return false;
@@ -855,6 +993,20 @@ function createSuggestionModePlugin(initialActive = false, author = "User") {
855
993
  if (event.key === "Backspace" || event.key === "Delete") {
856
994
  const { $from, empty } = view.state.selection;
857
995
  if (event.key === "Backspace" && empty && $from.parentOffset === 0 && $from.parent.type.name === "paragraph" && expectParagraphAttrs($from.parent).numPr?.kind === "reference") return false;
996
+ if (event.key === "Backspace" && clearIndentOnBackspace(view.state, (tr) => {
997
+ const revision = makeMarkAttrs(pluginState);
998
+ recordReplacedParagraphProperties({
999
+ tr,
1000
+ position: $from.before(),
1001
+ replaced: $from.parent,
1002
+ revision: {
1003
+ id: revision.revisionId,
1004
+ author: revision.author,
1005
+ date: revision.date
1006
+ }
1007
+ });
1008
+ view.dispatch(tr.setMeta("suggestionModeApplied", true));
1009
+ })) return true;
858
1010
  const boundaryTarget = paragraphBoundaryTarget(view.state, event.key === "Backspace" ? "backward" : "forward");
859
1011
  if (boundaryTarget !== null) return applyPPrDel(view, boundaryTarget, pluginState);
860
1012
  const direction = event.key === "Backspace" ? "backward" : "forward";
@@ -14,6 +14,12 @@ type DirectiveRange = {
14
14
  * array path the loop iterates (`{% for row in items %}` ⇒ `items`).
15
15
  */
16
16
  expr: string;
17
+ /**
18
+ * For an `if` / `elif` whose body is one field path plus a filter chain
19
+ * (`{% if consented | checkbox | label("…") %}`), that path; `expr` keeps
20
+ * the body as authored. Unset for every other tag.
21
+ */
22
+ conditionPath?: string;
17
23
  /** Clause-slot version selector, e.g. "v3" or "latest". */
18
24
  clauseVersion?: string;
19
25
  /** Loop alias of a `for` marker (`{% for row in items %}` ⇒ `row`); unset
@@ -3,9 +3,17 @@ import { collectBlockChunks, joinChunks, offsetToDocPos } from "./pmTextScan.js"
3
3
  import { PluginKey } from "prosemirror-state";
4
4
  import { assertNever, isBlockDirectiveKind, scanMarkers } from "@stll/template-conditions";
5
5
  //#region src/prosemirror/plugins/templateDirectives.ts
6
+ /**
7
+ * The body of an `if` / `elif` tag as authored: its inner text after the
8
+ * keyword. The grammar reads a body that is one path plus a filter chain as
9
+ * that path, with the chain in `filters`; the document still holds the whole
10
+ * body, and that is what a host keys a condition by.
11
+ */
12
+ const conditionBody = ({ inner, meta }) => inner.slice(meta.kind.length).trim();
6
13
  /** The display expression for a marker (field path, clause name, key,
7
- * condition, loop array path, loop property). */
8
- const directiveExpr = (meta) => {
14
+ * condition as authored, loop array path, loop property). */
15
+ const directiveExpr = (marker) => {
16
+ const { meta } = marker;
9
17
  switch (meta.kind) {
10
18
  case "placeholder": return meta.expr;
11
19
  case "clause": return meta.name;
@@ -13,7 +21,7 @@ const directiveExpr = (meta) => {
13
21
  case "ref": return meta.key;
14
22
  case "loop": return meta.property;
15
23
  case "if":
16
- case "elif": return meta.expr;
24
+ case "elif": return conditionBody(marker);
17
25
  case "for": return meta.path;
18
26
  case "else":
19
27
  case "endif":
@@ -63,6 +71,8 @@ const computeBlockDepths = (ranges) => {
63
71
  };
64
72
  /** The loop alias a `for` marker binds, or undefined for every other kind. */
65
73
  const directiveAlias = (meta) => meta.kind === "for" ? meta.alias : void 0;
74
+ /** The path a condition's filter chain configures, or undefined without a chain. */
75
+ const directiveConditionPath = (meta) => (meta.kind === "if" || meta.kind === "elif") && meta.filters.length > 0 ? meta.expr : void 0;
66
76
  const scanDirectives = (doc) => {
67
77
  const ranges = [];
68
78
  for (const chunks of collectBlockChunks(doc)) {
@@ -73,27 +83,31 @@ const scanDirectives = (doc) => {
73
83
  if (sole && sole.raw === trimmed && isBlockDirectiveKind(sole.meta.kind)) {
74
84
  const last = chunks.at(-1);
75
85
  const alias = directiveAlias(sole.meta);
86
+ const conditionPath = directiveConditionPath(sole.meta);
76
87
  ranges.push({
77
88
  from: chunks[0]?.start ?? 0,
78
89
  to: last ? last.end ?? last.start + last.text.length : 0,
79
90
  kind: sole.meta.kind,
80
- expr: directiveExpr(sole.meta),
91
+ expr: directiveExpr(sole),
81
92
  block: true,
82
- ...alias !== void 0 ? { alias } : {}
93
+ ...alias !== void 0 ? { alias } : {},
94
+ ...conditionPath !== void 0 ? { conditionPath } : {}
83
95
  });
84
96
  continue;
85
97
  }
86
98
  for (const marker of scanMarkers(joined)) {
87
99
  const clauseVersion = marker.meta.kind === "clause" ? marker.meta.version : void 0;
88
100
  const alias = directiveAlias(marker.meta);
101
+ const conditionPath = directiveConditionPath(marker.meta);
89
102
  ranges.push({
90
103
  from: offsetToDocPos(chunks, marker.start),
91
104
  to: offsetToDocPos(chunks, marker.end, "end"),
92
105
  kind: marker.meta.kind,
93
- expr: directiveExpr(marker.meta),
106
+ expr: directiveExpr(marker),
94
107
  block: false,
95
108
  ...clauseVersion !== void 0 ? { clauseVersion } : {},
96
- ...alias !== void 0 ? { alias } : {}
109
+ ...alias !== void 0 ? { alias } : {},
110
+ ...conditionPath !== void 0 ? { conditionPath } : {}
97
111
  });
98
112
  }
99
113
  }
@@ -1,7 +1,7 @@
1
1
  import { scanDirectives } from "./templateDirectives.js";
2
2
  import { Plugin, PluginKey } from "prosemirror-state";
3
3
  import { Decoration, DecorationSet } from "prosemirror-view";
4
- import { classifyMarker, isFieldPath } from "@stll/template-conditions";
4
+ import { isFieldPath } from "@stll/template-conditions";
5
5
  //#region src/prosemirror/plugins/templatePreviewValues.ts
6
6
  /** Concatenated plain text of a preview value. */
7
7
  const templatePreviewValueText = (value) => typeof value === "string" ? value : value.runs.map((run) => run.text).join("");
@@ -64,28 +64,21 @@ function buildValueWidget(value, mode) {
64
64
  return span;
65
65
  };
66
66
  }
67
- /** Loop alias the bare-path probe binds. Any identifier does; it is discarded. */
68
- const CHAIN_PROBE_ALIAS = "__folioCondition";
69
67
  /** A field path opens with a letter or underscore, so `9x` and `-x` are not one. */
70
68
  const FIELD_PATH_HEAD_RE = /^[\p{L}_]/u;
71
69
  /**
72
70
  * The bare field path a condition's filter chain hangs off, or undefined when
73
- * the expression is not a path plus a chain.
71
+ * the tag is not a path plus a chain.
74
72
  *
75
- * A host may write the tag the way `{% for x in xs | chain %}` already carries
76
- * one — `{% if buyer_is_a_consumer | checkbox | ai("Is the buyer …") %}` — while
77
- * keying `conditions` by the bare path. Splitting on `|` would cut a quoted
78
- * argument such as `label("a | b")` in half, so the expression is classified as
79
- * that very `for` chain instead and the grammar's own argument-aware scan
80
- * reports the path. An expression that is not a path plus a chain of known
81
- * filters (`a and b`, `items|length > 0`, `ai("a|b")`) classifies as nothing,
82
- * which is what keeps a real expression on exact matching.
73
+ * A host may write the tag with the chain of the boolean it names —
74
+ * `{% if buyer_is_a_consumer | checkbox | ai("Is the buyer …") %}` — while
75
+ * keying `conditions` by the bare path. The grammar's argument-aware scan
76
+ * splits the chain (`label("a | b")` stays one argument) and reports the path
77
+ * as `conditionPath`; an expression that is not a path plus a chain of known
78
+ * filters (`a and b`, `items|length > 0`, `ai("a|b")`) has none, which is what
79
+ * keeps a real expression on exact matching.
83
80
  */
84
- const filterChainPath = (expr) => {
85
- const meta = classifyMarker(`for ${CHAIN_PROBE_ALIAS} in ${expr}`, "statement");
86
- if (meta?.kind !== "for" || !isFieldPath(meta.path) || !FIELD_PATH_HEAD_RE.test(meta.path)) return;
87
- return meta.path;
88
- };
81
+ const filterChainPath = ({ conditionPath }) => conditionPath !== void 0 && isFieldPath(conditionPath) && FIELD_PATH_HEAD_RE.test(conditionPath) ? conditionPath : void 0;
89
82
  /**
90
83
  * The verdict stored under one key. Only a key the host set itself, holding a
91
84
  * real boolean, counts: an inherited property (`constructor`, or a boolean the
@@ -102,10 +95,10 @@ const ownVerdict = (conditions, key) => {
102
95
  * by the bare path its filter chain hangs off. `undefined` means the host said
103
96
  * nothing about this block, which leaves it exactly as authored.
104
97
  */
105
- const conditionVerdict = (conditions, expr) => {
106
- const exact = ownVerdict(conditions, expr);
98
+ const conditionVerdict = (conditions, opener) => {
99
+ const exact = ownVerdict(conditions, opener.expr);
107
100
  if (exact !== void 0) return exact;
108
- const path = filterChainPath(expr);
101
+ const path = filterChainPath(opener);
109
102
  return path === void 0 ? void 0 : ownVerdict(conditions, path);
110
103
  };
111
104
  /**
@@ -157,7 +150,7 @@ function collectHiddenRanges(ranges, conditions, mode) {
157
150
  }
158
151
  if (block === void 0 || block.opener.kind !== "if") continue;
159
152
  const { opener, branches } = block;
160
- const verdict = conditionVerdict(conditions, opener.expr);
153
+ const verdict = conditionVerdict(conditions, opener);
161
154
  if (verdict === void 0) continue;
162
155
  if (!verdict) {
163
156
  paired.push({
@@ -1,3 +1,4 @@
1
+ import { createNumberingIdAllocator } from "../docx/numberingIds.js";
1
2
  import { getCachedNumberingMap, isBulletLevel } from "../docx/numberingParser.js";
2
3
  import { paragraphNumberingLevel, paragraphNumberingReference, paragraphNumberingReferenceId } from "../docx/numberingReference.js";
3
4
  import { expectParagraphAttrs } from "./attrs/index.js";
@@ -20,16 +21,11 @@ const remappedAbstractNumId = (abstractNumId, remap) => {
20
21
  if (abstractNumId === null || abstractNumId === void 0 || remap.baseAbstractNumIds.has(abstractNumId)) return abstractNumId;
21
22
  let next = remap.abstractNumIds.get(abstractNumId);
22
23
  if (next === void 0) {
23
- next = remap.nextAbstractNumId++;
24
+ next = remap.abstractIds.next();
24
25
  remap.abstractNumIds.set(abstractNumId, next);
25
26
  }
26
27
  return next;
27
28
  };
28
- const maxOf = (ids, floor) => {
29
- let max = floor;
30
- for (const id of ids) max = Math.max(max, id);
31
- return max;
32
- };
33
29
  /**
34
30
  * The story in `state` as it should be written into a package whose numbering
35
31
  * is now `numbering`, and that numbering completed with the story's lists.
@@ -41,10 +37,16 @@ const storyListNumbering = (state, numbering) => {
41
37
  const definedMap = numbering ? getCachedNumberingMap(numbering) : null;
42
38
  const referenced = /* @__PURE__ */ new Set();
43
39
  const colliding = /* @__PURE__ */ new Set();
40
+ const existingAbstractNumIds = /* @__PURE__ */ new Set([...(numbering?.abstractNums ?? []).map(({ abstractNumId }) => abstractNumId), ...(packageDefinitions?.abstractNums ?? []).map(({ abstractNumId }) => abstractNumId)]);
44
41
  state.doc.descendants((node) => {
45
42
  if (node.type.name !== "paragraph") return true;
46
43
  for (const numId of paragraphListReferences(node)) referenced.add(numId);
47
44
  const attrs = expectParagraphAttrs(node);
45
+ if (typeof attrs.listAbstractNumId === "number") existingAbstractNumIds.add(attrs.listAbstractNumId);
46
+ for (const change of attrs._propertyChanges ?? []) {
47
+ const abstractId = change.previousFormatting?.listAbstractNumId;
48
+ if (typeof abstractId === "number") existingAbstractNumIds.add(abstractId);
49
+ }
48
50
  const numId = paragraphNumberingReferenceId(attrs.numPr);
49
51
  if (numId !== void 0 && !storyBase.has(numId) && definedMap?.hasNumbering(numId)) {
50
52
  const level = definedMap.getLevel(numId, paragraphNumberingLevel(attrs.numPr) ?? 0);
@@ -56,12 +58,12 @@ const storyListNumbering = (state, numbering) => {
56
58
  doc: state.doc,
57
59
  numbering: completeNumberingForDoc(numbering, state.doc)
58
60
  };
59
- let nextNumId = maxOf([...defined, ...referenced], 0) + 1;
61
+ const numIds = createNumberingIdAllocator("num", [...defined, ...referenced]);
60
62
  const remap = {
61
- numIds: new Map([...colliding].map((numId) => [numId, nextNumId++])),
63
+ numIds: new Map([...colliding].map((numId) => [numId, numIds.next()])),
62
64
  baseAbstractNumIds: new Set((packageDefinitions?.abstractNums ?? []).map(({ abstractNumId }) => abstractNumId)),
63
65
  abstractNumIds: /* @__PURE__ */ new Map(),
64
- nextAbstractNumId: maxOf((numbering?.abstractNums ?? []).map(({ abstractNumId }) => abstractNumId), -1) + 1
66
+ abstractIds: createNumberingIdAllocator("abstract", existingAbstractNumIds)
65
67
  };
66
68
  const tr = new Transform(state.doc);
67
69
  state.doc.descendants((node, pos) => {
@@ -15,7 +15,12 @@ type TextInput = {
15
15
  declare const dispatchEditorTextInput: <TView extends TextInputDispatchTarget<TView>>(view: TView, input: string | TextInput) => void;
16
16
  /** Shared DOM boundary for editor runtimes and standalone suggestion views. */
17
17
  declare const handleEditorBeforeInput: (view: EditorView, event: InputEvent) => boolean;
18
+ /**
19
+ * Plugin compositionend handlers run before ProseMirror queues its final DOM
20
+ * flush. A second microtask runs after that flush and sees the committed state.
21
+ */
22
+ declare const afterNativeCompositionFlush: (callback: () => void) => void;
18
23
  /** Route committed text through the model before the browser mutates mark spans. */
19
24
  declare const createTextInputPlugin: () => Plugin<any>;
20
25
  //#endregion
21
- export { createTextInputPlugin, dispatchEditorTextInput, handleEditorBeforeInput };
26
+ export { afterNativeCompositionFlush, createTextInputPlugin, dispatchEditorTextInput, handleEditorBeforeInput };
@@ -45,6 +45,13 @@ const handleEditorBeforeInput = (view, event) => {
45
45
  });
46
46
  return true;
47
47
  };
48
+ /**
49
+ * Plugin compositionend handlers run before ProseMirror queues its final DOM
50
+ * flush. A second microtask runs after that flush and sees the committed state.
51
+ */
52
+ const afterNativeCompositionFlush = (callback) => {
53
+ queueMicrotask(() => queueMicrotask(callback));
54
+ };
48
55
  /** Route committed text through the model before the browser mutates mark spans. */
49
56
  const createTextInputPlugin = () => {
50
57
  const compositions = /* @__PURE__ */ new WeakMap();
@@ -55,7 +62,7 @@ const createTextInputPlugin = () => {
55
62
  },
56
63
  compositionend(view, event) {
57
64
  compositions.set(view, event);
58
- queueMicrotask(() => {
65
+ afterNativeCompositionFlush(() => {
59
66
  if (compositions.get(view) === event) compositions.delete(view);
60
67
  });
61
68
  return false;
@@ -67,4 +74,4 @@ const createTextInputPlugin = () => {
67
74
  } } });
68
75
  };
69
76
  //#endregion
70
- export { createTextInputPlugin, dispatchEditorTextInput, handleEditorBeforeInput };
77
+ export { afterNativeCompositionFlush, createTextInputPlugin, dispatchEditorTextInput, handleEditorBeforeInput };