@blankdotpage/cake 0.1.75 → 0.1.77

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.
@@ -19,6 +19,7 @@ export function isApplyEditCommand(command) {
19
19
  command.type === "delete-forward");
20
20
  }
21
21
  const defaultSelection = { start: 0, end: 0, affinity: "forward" };
22
+ const WORD_CHARACTER_PATTERN = /[\p{L}\p{N}_]/u;
22
23
  function removeFromArray(arr, value) {
23
24
  const index = arr.indexOf(value);
24
25
  if (index === -1) {
@@ -39,6 +40,8 @@ export function createRuntimeForTests(extensions) {
39
40
  const structuralReparsePolicies = [];
40
41
  const domInlineRenderers = [];
41
42
  const domBlockRenderers = [];
43
+ const inlineHtmlSerializers = [];
44
+ const serializeSelectionLineToHtmlFns = [];
42
45
  const editor = {
43
46
  registerInlineWrapperAffinity: (specs) => {
44
47
  for (const spec of specs) {
@@ -124,6 +127,14 @@ export function createRuntimeForTests(extensions) {
124
127
  domBlockRenderers.push(fn);
125
128
  return () => removeFromArray(domBlockRenderers, fn);
126
129
  },
130
+ registerInlineHtmlSerializer: (fn) => {
131
+ inlineHtmlSerializers.push(fn);
132
+ return () => removeFromArray(inlineHtmlSerializers, fn);
133
+ },
134
+ registerSerializeSelectionLineToHtml: (fn) => {
135
+ serializeSelectionLineToHtmlFns.push(fn);
136
+ return () => removeFromArray(serializeSelectionLineToHtmlFns, fn);
137
+ },
127
138
  registerUI: () => {
128
139
  return () => { };
129
140
  },
@@ -144,11 +155,13 @@ export function createRuntimeForTests(extensions) {
144
155
  structuralReparsePolicies,
145
156
  domInlineRenderers,
146
157
  domBlockRenderers,
158
+ inlineHtmlSerializers,
159
+ serializeSelectionLineToHtmlFns,
147
160
  });
148
161
  return runtime;
149
162
  }
150
163
  export function createRuntimeFromRegistry(registry) {
151
- const { toggleMarkerToSpec, inclusiveAtEndByKind, parseBlockFns, parseInlineFns, serializeBlockFns, serializeInlineFns, normalizeBlockFns, normalizeInlineFns, onEditFns, structuralReparsePolicies, domInlineRenderers, domBlockRenderers, } = registry;
164
+ const { toggleMarkerToSpec, inclusiveAtEndByKind, parseBlockFns, parseInlineFns, serializeBlockFns, serializeInlineFns, normalizeBlockFns, normalizeInlineFns, onEditFns, structuralReparsePolicies, domInlineRenderers, domBlockRenderers, inlineHtmlSerializers, serializeSelectionLineToHtmlFns, } = registry;
152
165
  const isInclusiveAtEnd = (kind) => inclusiveAtEndByKind.get(kind) ?? true;
153
166
  const removedBlockSentinel = Symbol("removed-block");
154
167
  const removedInlineSentinel = Symbol("removed-inline");
@@ -347,13 +360,17 @@ export function createRuntimeFromRegistry(registry) {
347
360
  }
348
361
  content.push(normalizedInline);
349
362
  }
363
+ const mergedContent = mergeAdjacentInlines(content);
364
+ if (mergedContent !== content) {
365
+ changed = true;
366
+ }
350
367
  if (!changed) {
351
368
  normalizedBlockCache.set(block, next);
352
369
  return next;
353
370
  }
354
371
  const normalized = {
355
372
  ...next,
356
- content,
373
+ content: mergedContent,
357
374
  };
358
375
  normalizedBlockCache.set(block, normalized);
359
376
  return normalized;
@@ -409,17 +426,52 @@ export function createRuntimeFromRegistry(registry) {
409
426
  }
410
427
  let next = pre;
411
428
  if (next.type === "inline-wrapper") {
429
+ const children = mergeAdjacentInlines(next.children
430
+ .map((child) => normalizeInline(child))
431
+ .filter((child) => child !== null));
412
432
  next = {
413
433
  ...next,
414
- children: next.children
415
- .map((child) => normalizeInline(child))
416
- .filter((child) => child !== null),
434
+ children,
417
435
  };
418
436
  }
419
437
  const normalized = applyInlineNormalizers(next);
420
438
  normalizedInlineCache.set(inline, normalized ?? removedInlineSentinel);
421
439
  return normalized;
422
440
  }
441
+ function mergeAdjacentInlines(inlines) {
442
+ if (inlines.length < 2) {
443
+ return inlines;
444
+ }
445
+ const merged = [];
446
+ let changed = false;
447
+ for (const inline of inlines) {
448
+ const previous = merged[merged.length - 1];
449
+ if (previous?.type === "text" && inline.type === "text") {
450
+ merged[merged.length - 1] = {
451
+ ...previous,
452
+ text: previous.text + inline.text,
453
+ };
454
+ changed = true;
455
+ continue;
456
+ }
457
+ if (previous?.type === "inline-wrapper" &&
458
+ inline.type === "inline-wrapper" &&
459
+ previous.kind === inline.kind &&
460
+ stableStringify(previous.data) === stableStringify(inline.data)) {
461
+ merged[merged.length - 1] = {
462
+ ...previous,
463
+ children: mergeAdjacentInlines([
464
+ ...previous.children,
465
+ ...inline.children,
466
+ ]),
467
+ };
468
+ changed = true;
469
+ continue;
470
+ }
471
+ merged.push(inline);
472
+ }
473
+ return changed ? merged : inlines;
474
+ }
423
475
  function createTopLevelBlockSegment(block) {
424
476
  const serialized = serializeBlock(block);
425
477
  return {
@@ -571,9 +623,15 @@ export function createRuntimeFromRegistry(registry) {
571
623
  ? previousSegmented
572
624
  : undefined;
573
625
  const segmented = buildSegmentedDocState(normalized, reusablePrevious);
626
+ const cursorLength = segmented.map.cursorLength;
627
+ const clampedSelection = {
628
+ ...selection,
629
+ start: Math.max(0, Math.min(cursorLength, selection.start)),
630
+ end: Math.max(0, Math.min(cursorLength, selection.end)),
631
+ };
574
632
  return {
575
633
  source: segmented.source,
576
- selection,
634
+ selection: normalizeSelection(clampedSelection),
577
635
  map: segmented.map,
578
636
  doc: normalized,
579
637
  runtime: runtime,
@@ -725,7 +783,8 @@ export function createRuntimeFromRegistry(registry) {
725
783
  previousState: useIncrementalSegmentedDerivation ? state : undefined,
726
784
  });
727
785
  const interimAffinity = structural.nextAffinity ?? "forward";
728
- const caretSource = interim.map.cursorToSource(structural.nextCursor, interimAffinity);
786
+ const interimCursor = Math.max(0, Math.min(interim.map.cursorLength, structural.nextCursor));
787
+ const caretSource = interim.map.cursorToSource(interimCursor, interimAffinity);
729
788
  if (useIncrementalSegmentedDerivation) {
730
789
  const caretCursor = interim.map.sourceToCursor(caretSource, interimAffinity);
731
790
  return {
@@ -812,6 +871,27 @@ export function createRuntimeFromRegistry(registry) {
812
871
  replaceText.length > 0 &&
813
872
  range.start === range.end &&
814
873
  textModel.getGraphemeAtCursor(range.start) === "\u200B";
874
+ if (command.type === "insert" && shouldReplacePlaceholder) {
875
+ const leadingWhitespace = replaceText.match(/^\s+/)?.[0] ?? "";
876
+ const around = marksAroundCursor(doc, range.start);
877
+ if (leadingWhitespace.length > 0) {
878
+ if (isMarksPrefix(around.left, around.right) &&
879
+ around.right.length > around.left.length) {
880
+ const whitespaceInsert = insertTextBeforePendingPlaceholderInDoc(doc, range.start, leadingWhitespace, around.left);
881
+ if (whitespaceInsert) {
882
+ const trailingText = replaceText.slice(leadingWhitespace.length);
883
+ if (trailingText.length === 0) {
884
+ return whitespaceInsert;
885
+ }
886
+ return applyStructuralEdit({ type: "insert", text: trailingText }, whitespaceInsert.doc, {
887
+ start: whitespaceInsert.nextCursor,
888
+ end: whitespaceInsert.nextCursor,
889
+ affinity: whitespaceInsert.nextAffinity,
890
+ });
891
+ }
892
+ }
893
+ }
894
+ }
815
895
  const effectiveRange = shouldReplacePlaceholder
816
896
  ? { start: range.start, end: Math.min(docCursorLength, range.start + 1) }
817
897
  : range;
@@ -1174,6 +1254,18 @@ export function createRuntimeFromRegistry(registry) {
1174
1254
  return true;
1175
1255
  });
1176
1256
  }
1257
+ function mergeMarksPreservingOrder(...groups) {
1258
+ const next = [];
1259
+ for (const group of groups) {
1260
+ for (const mark of group) {
1261
+ if (next.some((existing) => existing.key === mark.key)) {
1262
+ continue;
1263
+ }
1264
+ next.push(mark);
1265
+ }
1266
+ }
1267
+ return next;
1268
+ }
1177
1269
  function sliceRuns(runs, startCursor, endCursor) {
1178
1270
  const [left, rest] = splitRunsAt(runs, startCursor);
1179
1271
  const [selected, right] = splitRunsAt(rest, Math.max(0, endCursor - startCursor));
@@ -1330,27 +1422,188 @@ export function createRuntimeFromRegistry(registry) {
1330
1422
  },
1331
1423
  };
1332
1424
  }
1333
- function canPreserveRemainingMarksAtBoundary(state, cursorOffset, targetKind, remainingMarks) {
1425
+ function rewritePendingPlaceholderAtCursor(state, cursorOffset, marks) {
1334
1426
  const textModel = getEditorTextModelForDoc(state.doc);
1335
1427
  const lines = textModel.getStructuralLines();
1336
1428
  const loc = textModel.resolveOffsetToLine(cursorOffset);
1337
1429
  const line = lines[loc.lineIndex];
1338
1430
  if (!line) {
1339
- return false;
1431
+ return null;
1340
1432
  }
1341
1433
  const block = getBlockAtPath(state.doc.blocks, line.path);
1342
1434
  if (!block || block.type !== "paragraph") {
1343
- return false;
1435
+ return null;
1344
1436
  }
1437
+ const placeholder = "\u200B";
1345
1438
  const runs = paragraphToRuns(block);
1346
- const { before } = sliceRuns(runs, loc.offsetInLine, loc.offsetInLine);
1347
- const remainingKeys = remainingMarks.map((mark) => mark.key);
1348
- return before.every((run) => {
1349
- if (!run.marks.some((mark) => mark.kind === targetKind)) {
1350
- return true;
1439
+ const { before, after } = sliceRuns(runs, loc.offsetInLine, loc.offsetInLine);
1440
+ const replacement = [];
1441
+ const firstAfter = after[0];
1442
+ if (firstAfter?.type === "text" && firstAfter.text.startsWith(placeholder)) {
1443
+ if (marks && marks.length > 0) {
1444
+ replacement.push({ type: "text", text: placeholder, marks });
1445
+ }
1446
+ if (firstAfter.text.length > placeholder.length) {
1447
+ replacement.push({
1448
+ ...firstAfter,
1449
+ text: firstAfter.text.slice(placeholder.length),
1450
+ });
1351
1451
  }
1352
- return remainingKeys.every((key) => run.marks.some((mark) => mark.key === key));
1353
- });
1452
+ replacement.push(...after.slice(1));
1453
+ }
1454
+ else {
1455
+ const lastBefore = before[before.length - 1];
1456
+ if (lastBefore?.type !== "text" ||
1457
+ !lastBefore.text.endsWith(placeholder)) {
1458
+ return null;
1459
+ }
1460
+ const prefix = lastBefore.text.slice(0, -placeholder.length);
1461
+ if (prefix) {
1462
+ replacement.push({ ...lastBefore, text: prefix });
1463
+ }
1464
+ if (marks && marks.length > 0) {
1465
+ replacement.push({ type: "text", text: placeholder, marks });
1466
+ }
1467
+ replacement.push(...after);
1468
+ before.pop();
1469
+ }
1470
+ const mergedRuns = normalizeRuns([...before, ...replacement]);
1471
+ const nextBlock = {
1472
+ ...block,
1473
+ content: runsToInlines(mergedRuns),
1474
+ };
1475
+ const parentPath = line.path.slice(0, -1);
1476
+ const indexInParent = line.path[line.path.length - 1] ?? 0;
1477
+ const nextDoc = {
1478
+ ...state.doc,
1479
+ blocks: updateBlocksAtPath(state.doc.blocks, parentPath, (blocks) => blocks.map((child, index) => index === indexInParent ? nextBlock : child)),
1480
+ };
1481
+ const next = createStateFromDoc(nextDoc);
1482
+ return {
1483
+ ...next,
1484
+ selection: {
1485
+ start: cursorOffset,
1486
+ end: cursorOffset,
1487
+ affinity: marks && marks.length > 0 ? "forward" : "backward",
1488
+ },
1489
+ };
1490
+ }
1491
+ function updatePendingPlaceholderMarksAtCursor(state, cursorOffset, marks) {
1492
+ return rewritePendingPlaceholderAtCursor(state, cursorOffset, marks);
1493
+ }
1494
+ function removePendingPlaceholderAtCursor(state, cursorOffset) {
1495
+ return rewritePendingPlaceholderAtCursor(state, cursorOffset, null);
1496
+ }
1497
+ function getPendingPlaceholderMarksAtCursor(state, cursorOffset) {
1498
+ const textModel = getEditorTextModelForDoc(state.doc);
1499
+ const lines = textModel.getStructuralLines();
1500
+ const loc = textModel.resolveOffsetToLine(cursorOffset);
1501
+ const line = lines[loc.lineIndex];
1502
+ if (!line) {
1503
+ return null;
1504
+ }
1505
+ const block = getBlockAtPath(state.doc.blocks, line.path);
1506
+ if (!block || block.type !== "paragraph") {
1507
+ return null;
1508
+ }
1509
+ const placeholder = "\u200B";
1510
+ const runs = paragraphToRuns(block);
1511
+ const { before, after } = sliceRuns(runs, loc.offsetInLine, loc.offsetInLine);
1512
+ const firstAfter = after[0];
1513
+ if (firstAfter?.type === "text" && firstAfter.text.startsWith(placeholder)) {
1514
+ return firstAfter.marks;
1515
+ }
1516
+ const lastBefore = before[before.length - 1];
1517
+ if (lastBefore?.type === "text" &&
1518
+ lastBefore.text.endsWith(placeholder)) {
1519
+ return lastBefore.marks;
1520
+ }
1521
+ return null;
1522
+ }
1523
+ function insertTextBeforePendingPlaceholderInDoc(doc, cursorOffset, text, marks) {
1524
+ const textModel = getEditorTextModelForDoc(doc);
1525
+ const lines = textModel.getStructuralLines();
1526
+ const loc = textModel.resolveOffsetToLine(cursorOffset);
1527
+ const line = lines[loc.lineIndex];
1528
+ if (!line) {
1529
+ return null;
1530
+ }
1531
+ const block = getBlockAtPath(doc.blocks, line.path);
1532
+ if (!block || block.type !== "paragraph") {
1533
+ return null;
1534
+ }
1535
+ const placeholder = "\u200B";
1536
+ const runs = paragraphToRuns(block);
1537
+ const { before, after } = sliceRuns(runs, loc.offsetInLine, loc.offsetInLine);
1538
+ const firstAfter = after[0];
1539
+ if (firstAfter?.type !== "text" ||
1540
+ !firstAfter.text.startsWith(placeholder)) {
1541
+ return null;
1542
+ }
1543
+ const mergedRuns = normalizeRuns([
1544
+ ...before,
1545
+ ...(text.length > 0 ? [{ type: "text", text, marks }] : []),
1546
+ firstAfter,
1547
+ ...after.slice(1),
1548
+ ]);
1549
+ const nextBlock = {
1550
+ ...block,
1551
+ content: runsToInlines(mergedRuns),
1552
+ };
1553
+ const parentPath = line.path.slice(0, -1);
1554
+ const indexInParent = line.path[line.path.length - 1] ?? 0;
1555
+ const nextDoc = {
1556
+ ...doc,
1557
+ blocks: updateBlocksAtPath(doc.blocks, parentPath, (blocks) => blocks.map((child, index) => index === indexInParent ? nextBlock : child)),
1558
+ };
1559
+ return {
1560
+ doc: nextDoc,
1561
+ nextCursor: cursorOffset + Array.from(graphemeSegments(text)).length,
1562
+ nextAffinity: "forward",
1563
+ };
1564
+ }
1565
+ function hasInlineMarkerBoundaryBefore(source, markerStart) {
1566
+ if (markerStart <= 0) {
1567
+ return true;
1568
+ }
1569
+ return !WORD_CHARACTER_PATTERN.test(source[markerStart - 1] ?? "");
1570
+ }
1571
+ function pickSafeCollapsedToggleMarkerSpec(params) {
1572
+ const { defaultSpec, source, insertAt, affinity } = params;
1573
+ const candidates = Array.from(toggleMarkerToSpec.values()).filter((spec, index, all) => spec.kind === defaultSpec.kind &&
1574
+ all.findIndex((candidate) => candidate.kind === spec.kind &&
1575
+ candidate.open === spec.open &&
1576
+ candidate.close === spec.close) === index);
1577
+ if (candidates.length <= 1) {
1578
+ return defaultSpec;
1579
+ }
1580
+ const previousChar = source[insertAt - 1] ?? "";
1581
+ const nextChar = source[insertAt] ?? "";
1582
+ let bestSpec = defaultSpec;
1583
+ let bestScore = Number.POSITIVE_INFINITY;
1584
+ for (const spec of candidates) {
1585
+ if (spec.open === "_" &&
1586
+ !hasInlineMarkerBoundaryBefore(source, insertAt)) {
1587
+ continue;
1588
+ }
1589
+ let score = 0;
1590
+ if (previousChar && spec.open[0] === previousChar) {
1591
+ score += affinity === "forward" ? 8 : 3;
1592
+ }
1593
+ if (nextChar &&
1594
+ spec.close[spec.close.length - 1] === nextChar) {
1595
+ score += affinity === "backward" ? 8 : 3;
1596
+ }
1597
+ if (spec.open === defaultSpec.open &&
1598
+ spec.close === defaultSpec.close) {
1599
+ score -= 0.5;
1600
+ }
1601
+ if (score < bestScore) {
1602
+ bestSpec = spec;
1603
+ bestScore = score;
1604
+ }
1605
+ }
1606
+ return bestSpec;
1354
1607
  }
1355
1608
  function preferredAffinityAtGap(left, right, fallback) {
1356
1609
  if (isMarksPrefix(left, right) && right.length > left.length) {
@@ -1611,18 +1864,39 @@ export function createRuntimeFromRegistry(registry) {
1611
1864
  };
1612
1865
  if (selection.start === selection.end) {
1613
1866
  const caret = selection.start;
1867
+ const pendingPlaceholderMarks = getPendingPlaceholderMarksAtCursor(state, caret);
1868
+ if (pendingPlaceholderMarks) {
1869
+ const hasMarker = pendingPlaceholderMarks.some((mark) => mark.kind === markerKind);
1870
+ const around = marksAroundCursor(state.doc, caret);
1871
+ const nextMarks = hasMarker
1872
+ ? removeMarkByKind(pendingPlaceholderMarks, markerKind)
1873
+ : mergeMarksPreservingOrder(around.left, pendingPlaceholderMarks, [markerMark]);
1874
+ const next = nextMarks.length > 0
1875
+ ? updatePendingPlaceholderMarksAtCursor(state, caret, nextMarks)
1876
+ : removePendingPlaceholderAtCursor(state, caret);
1877
+ if (next) {
1878
+ return {
1879
+ ...next,
1880
+ selection: {
1881
+ start: caret,
1882
+ end: caret,
1883
+ affinity: "forward",
1884
+ },
1885
+ };
1886
+ }
1887
+ }
1614
1888
  // When the caret is at the end boundary of an inline wrapper, toggling the
1615
1889
  // wrapper should "exit" it (so the next character types outside). This is
1616
1890
  // best expressed in cursor space by flipping affinity to "forward" when we
1617
1891
  // are leaving a wrapper of the requested kind.
1618
1892
  const around = marksAroundCursor(state.doc, caret);
1619
1893
  if (isMarksPrefix(around.right, around.left) &&
1620
- around.left.length > around.right.length) {
1894
+ around.left.length > around.right.length &&
1895
+ (selection.affinity ?? "forward") === "backward") {
1621
1896
  const exiting = around.left.slice(around.right.length);
1622
1897
  if (exiting.some((mark) => mark.kind === markerKind)) {
1623
1898
  const remainingMarks = removeMarkByKind(around.left, markerKind);
1624
- if (!marksEqual(remainingMarks, around.right) &&
1625
- canPreserveRemainingMarksAtBoundary(state, caret, markerKind, remainingMarks)) {
1899
+ if (!marksEqual(remainingMarks, around.right)) {
1626
1900
  const next = createPendingPlaceholderStateAtCursor(state, caret, remainingMarks);
1627
1901
  if (next) {
1628
1902
  return next;
@@ -1645,6 +1919,20 @@ export function createRuntimeFromRegistry(registry) {
1645
1919
  around.right.length > around.left.length) {
1646
1920
  const entering = around.right.slice(around.left.length);
1647
1921
  if (entering.some((mark) => mark.kind === markerKind)) {
1922
+ const remainingMarks = removeMarkByKind(around.right, markerKind);
1923
+ const next = remainingMarks.length > 0
1924
+ ? updatePendingPlaceholderMarksAtCursor(state, caret, remainingMarks)
1925
+ : removePendingPlaceholderAtCursor(state, caret);
1926
+ if (next) {
1927
+ return {
1928
+ ...next,
1929
+ selection: {
1930
+ start: caret,
1931
+ end: caret,
1932
+ affinity: "backward",
1933
+ },
1934
+ };
1935
+ }
1648
1936
  const insertAtBackward = map.cursorToSource(caret, "backward");
1649
1937
  const insertAtForward = map.cursorToSource(caret, "forward");
1650
1938
  const after = source.slice(insertAtBackward);
@@ -1705,6 +1993,7 @@ export function createRuntimeFromRegistry(registry) {
1705
1993
  }
1706
1994
  if (isMarksPrefix(around.right, around.left) &&
1707
1995
  around.left.length > around.right.length &&
1996
+ (selection.affinity ?? "forward") === "backward" &&
1708
1997
  !around.left.some((mark) => mark.kind === markerKind)) {
1709
1998
  const next = createPendingPlaceholderStateAtCursor(state, caret, [
1710
1999
  ...around.left,
@@ -1735,34 +2024,63 @@ export function createRuntimeFromRegistry(registry) {
1735
2024
  return null;
1736
2025
  })();
1737
2026
  // When at a boundary between cursor positions (insertAtBackward !== insertAtForward),
1738
- // prefer insertAtBackward to keep new markers inside the current formatting context.
1739
- // However, only do this if the new marker length is <= the boundary marker length,
1740
- // otherwise we create ambiguous marker sequences (e.g., *italic**​*** doesn't parse).
2027
+ // only prefer insertAtBackward if the caret is intentionally anchored inside the
2028
+ // left formatting context. If the caret affinity is forward, the user explicitly
2029
+ // exited that wrapper and new markers belong on the forward side of the gap.
2030
+ // Still guard against inserting a longer marker into a shorter boundary run,
2031
+ // which would create ambiguous source (e.g., *italic**​***).
1741
2032
  const betweenLen = insertAtForward - insertAtBackward;
1742
- const preferBackward = insertAtBackward !== insertAtForward && openLen <= betweenLen;
2033
+ const preferBackward = insertAtBackward !== insertAtForward &&
2034
+ (selection.affinity ?? "forward") === "backward" &&
2035
+ openLen <= betweenLen;
1743
2036
  const insertAt = placeholderPos ?? (preferBackward ? insertAtBackward : insertAtForward);
2037
+ const insertMarkerSpec = placeholderPos === null
2038
+ ? pickSafeCollapsedToggleMarkerSpec({
2039
+ defaultSpec: markerSpec,
2040
+ source,
2041
+ insertAt,
2042
+ affinity: selection.affinity ?? "forward",
2043
+ })
2044
+ : markerSpec;
2045
+ const insertOpenMarker = insertMarkerSpec.open;
2046
+ const insertCloseMarker = insertMarkerSpec.close;
2047
+ const insertOpenLen = insertOpenMarker.length;
2048
+ const baseMarks = (selection.affinity ?? "forward") === "backward"
2049
+ ? around.left
2050
+ : around.right;
2051
+ const nextMarks = [
2052
+ ...baseMarks.filter((mark) => mark.kind !== markerKind),
2053
+ markerMark,
2054
+ ];
2055
+ if (placeholderPos !== null) {
2056
+ const next = updatePendingPlaceholderMarksAtCursor(state, caret, nextMarks);
2057
+ if (next) {
2058
+ return next;
2059
+ }
2060
+ }
2061
+ const docInserted = createPendingPlaceholderStateAtCursor(state, caret, nextMarks);
2062
+ if (docInserted) {
2063
+ return docInserted;
2064
+ }
1744
2065
  const nextSource = placeholderPos !== null
1745
2066
  ? source.slice(0, insertAt) +
1746
- openMarker +
2067
+ insertOpenMarker +
1747
2068
  placeholder +
1748
- closeMarker +
2069
+ insertCloseMarker +
1749
2070
  source.slice(insertAt + placeholder.length)
1750
2071
  : source.slice(0, insertAt) +
1751
- openMarker +
2072
+ insertOpenMarker +
1752
2073
  placeholder +
1753
- closeMarker +
2074
+ insertCloseMarker +
1754
2075
  source.slice(insertAt);
1755
2076
  const next = createState(nextSource);
1756
- const placeholderStart = insertAt + openLen;
2077
+ const placeholderStart = insertAt + insertOpenLen;
1757
2078
  const startCursor = next.map.sourceToCursor(placeholderStart, "forward");
1758
- return {
1759
- ...next,
1760
- selection: {
1761
- start: startCursor.cursorOffset,
1762
- end: startCursor.cursorOffset,
1763
- affinity: "forward",
1764
- },
1765
- };
2079
+ return createStateFromDoc(next.doc, {
2080
+ start: startCursor.cursorOffset,
2081
+ end: startCursor.cursorOffset,
2082
+ affinity: "forward",
2083
+ });
1766
2084
  }
1767
2085
  const cursorStart = Math.min(selection.start, selection.end);
1768
2086
  const cursorEnd = Math.max(selection.start, selection.end);
@@ -2004,18 +2322,12 @@ export function createRuntimeFromRegistry(registry) {
2004
2322
  // Apply marks in reverse order so outer marks wrap inner marks
2005
2323
  const sortedMarks = [...run.marks].reverse();
2006
2324
  for (const mark of sortedMarks) {
2007
- if (mark.kind === "bold") {
2008
- content = `<strong>${content}</strong>`;
2009
- }
2010
- else if (mark.kind === "italic") {
2011
- content = `<em>${content}</em>`;
2012
- }
2013
- else if (mark.kind === "strikethrough") {
2014
- content = `<s>${content}</s>`;
2015
- }
2016
- else if (mark.kind === "link") {
2017
- const url = mark.data?.url ?? "";
2018
- content = `<a href="${escapeHtml(url)}">${content}</a>`;
2325
+ for (const serializeMarkToHtml of inlineHtmlSerializers) {
2326
+ const next = serializeMarkToHtml({ kind: mark.kind, data: mark.data }, content, { escapeHtml });
2327
+ if (next !== null) {
2328
+ content = next;
2329
+ break;
2330
+ }
2019
2331
  }
2020
2332
  }
2021
2333
  html += content;
@@ -2035,22 +2347,13 @@ export function createRuntimeFromRegistry(registry) {
2035
2347
  const startLoc = textModel.resolveOffsetToLine(cursorStart);
2036
2348
  const endLoc = textModel.resolveOffsetToLine(cursorEnd);
2037
2349
  let html = "";
2038
- let activeList = null;
2039
- const closeList = () => {
2040
- if (activeList) {
2041
- html += `</${activeList.type}>`;
2042
- activeList = null;
2043
- }
2044
- };
2045
- const openList = (type, indent) => {
2046
- if (activeList &&
2047
- activeList.type === type &&
2048
- activeList.indent === indent) {
2350
+ let activeGroup = null;
2351
+ const closeGroup = () => {
2352
+ if (!activeGroup) {
2049
2353
  return;
2050
2354
  }
2051
- closeList();
2052
- html += `<${type}>`;
2053
- activeList = { type, indent };
2355
+ html += activeGroup.close;
2356
+ activeGroup = null;
2054
2357
  };
2055
2358
  for (let lineIndex = startLoc.lineIndex; lineIndex <= endLoc.lineIndex; lineIndex += 1) {
2056
2359
  const line = lines[lineIndex];
@@ -2067,66 +2370,46 @@ export function createRuntimeFromRegistry(registry) {
2067
2370
  ? endLoc.offsetInLine
2068
2371
  : line.cursorLength;
2069
2372
  const selectedRuns = sliceRuns(runs, startInLine, endInLine).selected;
2070
- // Check if this line is inside a block-wrapper (heading or list)
2071
- let wrapperKind = null;
2072
- let wrapperData;
2373
+ const lineHtml = runsToHtml(normalizeRuns(selectedRuns));
2374
+ const lineText = runs
2375
+ .map((r) => (r.type === "text" ? r.text : " "))
2376
+ .join("");
2377
+ let wrapperBlock = null;
2073
2378
  if (line.path.length > 1) {
2074
2379
  const wrapperPath = line.path.slice(0, -1);
2075
2380
  const wrapper = getBlockAtPath(state.doc.blocks, wrapperPath);
2076
2381
  if (wrapper && wrapper.type === "block-wrapper") {
2077
- wrapperKind = wrapper.kind;
2078
- wrapperData = wrapper.data;
2382
+ wrapperBlock = wrapper;
2079
2383
  }
2080
2384
  }
2081
- // Extract plain text to check for list patterns
2082
- const plainText = runs
2083
- .map((r) => (r.type === "text" ? r.text : " "))
2084
- .join("");
2085
- const listMatch = plainText.match(/^(\s*)([-*+]|\d+\.)( )(.*)$/);
2086
- // Determine the HTML content - strip list prefix if it's a list line
2087
- let lineHtml;
2088
- if (listMatch && !wrapperKind) {
2089
- // For list lines, only include the content after the prefix
2090
- const prefixLength = Array.from(graphemeSegments(`${listMatch[1]}${listMatch[2]}${listMatch[3]}`)).length;
2091
- const contentRuns = sliceRuns(runs, prefixLength, runs.reduce((sum, r) => sum +
2092
- (r.type === "text"
2093
- ? Array.from(graphemeSegments(r.text)).length
2094
- : 1), 0)).selected;
2095
- lineHtml = runsToHtml(normalizeRuns(contentRuns));
2096
- }
2097
- else {
2098
- lineHtml = runsToHtml(normalizeRuns(selectedRuns));
2099
- }
2100
- if (wrapperKind === "heading") {
2101
- closeList();
2102
- const level = Math.min(wrapperData?.level ?? 1, 6);
2103
- html += `<h${level} style="margin:0">${lineHtml}</h${level}>`;
2104
- }
2105
- else if (wrapperKind === "bullet-list") {
2106
- openList("ul", 0);
2107
- html += `<li>${lineHtml}</li>`;
2108
- }
2109
- else if (wrapperKind === "numbered-list") {
2110
- openList("ol", 0);
2111
- html += `<li>${lineHtml}</li>`;
2112
- }
2113
- else if (wrapperKind === "blockquote") {
2114
- closeList();
2115
- html += `<blockquote>${lineHtml}</blockquote>`;
2116
- }
2117
- else if (listMatch) {
2118
- // Plain paragraph with list markers (cake v3 list model)
2119
- const isNumbered = /^\d+\.$/.test(listMatch[2]);
2120
- const indent = Math.floor(listMatch[1].length / 2);
2121
- openList(isNumbered ? "ol" : "ul", indent);
2122
- html += `<li>${lineHtml}</li>`;
2123
- }
2124
- else {
2125
- closeList();
2126
- html += `<div>${lineHtml}</div>`;
2127
- }
2128
- }
2129
- closeList();
2385
+ let lineResult = null;
2386
+ for (const serializeLineToHtml of serializeSelectionLineToHtmlFns) {
2387
+ lineResult = serializeLineToHtml({
2388
+ state,
2389
+ line,
2390
+ block,
2391
+ wrapperBlock,
2392
+ lineText,
2393
+ startInLine,
2394
+ endInLine,
2395
+ lineCursorLength: line.cursorLength,
2396
+ selectedHtml: lineHtml,
2397
+ });
2398
+ if (lineResult) {
2399
+ break;
2400
+ }
2401
+ }
2402
+ const group = lineResult?.group ?? null;
2403
+ if (!group || !activeGroup || activeGroup.key !== group.key) {
2404
+ closeGroup();
2405
+ if (group) {
2406
+ html += group.open;
2407
+ activeGroup = group;
2408
+ }
2409
+ }
2410
+ html += lineResult?.html ?? `<div>${lineHtml}</div>`;
2411
+ }
2412
+ closeGroup();
2130
2413
  if (!html) {
2131
2414
  return "";
2132
2415
  }