@superdoc/sdk 2.27.0-next.9 → 2.28.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.
@@ -11,6 +11,8 @@ var structureInsert = require('../action-primitives/tools/structure-insert.cjs')
11
11
  var textTransform = require('../action-primitives/tools/text-transform.cjs');
12
12
  var docSnapshot = require('./doc-snapshot.cjs');
13
13
  var guards = require('../guards.cjs');
14
+ var leadInLabel = require('./lead-in-label.cjs');
15
+ var insertedBlockFormatting = require('./inserted-block-formatting.cjs');
14
16
 
15
17
  const ACTION_DEFINITIONS = actionDescriptors;
16
18
  function projectActionDefinitions(project) {
@@ -2672,10 +2674,9 @@ async function runRewriteBlock(doc, args) {
2672
2674
  return context.failure('rewrite_block', error);
2673
2675
  }
2674
2676
  }
2675
- const RUN_BOOL_MARKS = ['bold', 'italic', 'underline', 'strike'];
2676
2677
  function inlineFromSnapshotRun(run) {
2677
2678
  const inline = {};
2678
- for (const key of RUN_BOOL_MARKS) {
2679
+ for (const key of insertedBlockFormatting.RUN_BOOL_MARKS) {
2679
2680
  if (run[key])
2680
2681
  inline[key] = true;
2681
2682
  }
@@ -5189,112 +5190,8 @@ async function runScopedReplace(doc, args) {
5189
5190
  */
5190
5191
  /** Points → twips: the node read side reports points, mutations take twips. */
5191
5192
  const TWIPS_PER_POINT = 20;
5192
- /**
5193
- * Delimiters that separate a list item's lead-in label from its body text.
5194
- * Spaced dashes are listed separately from bare ones because the label run
5195
- * usually stops BEFORE the space ("JavaScript / TypeScript" + " — SuperDoc…").
5196
- */
5197
- const LEAD_IN_DELIMITERS = [':', ' — ', ' – ', ' - ', '—', '–'];
5198
- /** A lead-in is a LABEL, not a sentence — longer spans are prose, not a pattern. */
5199
- const LEAD_IN_MAX_CHARS = 90;
5200
- /** Sentence characters sampled past the label, to read the body's own look. */
5201
- const LEAD_IN_BODY_SAMPLE_CHARS = 40;
5202
- /**
5203
- * Where an item's lead-in label ends.
5204
- *
5205
- * The SIBLINGS' delimiter wins whenever the item uses it: with a dash-style
5206
- * list, "Rust: Advanced — details" splits at the dash, not at the colon inside
5207
- * the label. Falling back to the earliest known delimiter is only for items
5208
- * that do not use the siblings' one at all.
5209
- */
5210
- function leadInEndOf(text, preferred) {
5211
- const at = (delimiter) => {
5212
- const index = text.indexOf(delimiter);
5213
- return index > 0 && index <= LEAD_IN_MAX_CHARS ? index : null;
5214
- };
5215
- if (preferred) {
5216
- const preferredAt = at(preferred);
5217
- if (preferredAt != null)
5218
- return preferredAt;
5219
- }
5220
- const found = LEAD_IN_DELIMITERS.map(at).filter((index) => index != null);
5221
- return found.length > 0 ? Math.min(...found) : null;
5222
- }
5223
- /** Quote characters a defined term is wrapped in ("Deliverables" means …). */
5224
- const LEAD_IN_QUOTES = ['"', '\u201c', '\u2018', "'"];
5225
- /**
5226
- * Delimiters allowed to sit INSIDE the label run. Wider than the set used to
5227
- * split at a delimiter that follows the label, because a sentence period only
5228
- * counts as a label boundary when the reference's own emphasised run ends with
5229
- * it — "1.7. Records. " — which is evidence no bare text scan can provide.
5230
- */
5231
- const LEAD_IN_TAIL_DELIMITERS = ['. ', ': ', ...LEAD_IN_DELIMITERS];
5232
- /**
5233
- * Read the label shape a reference block uses, from its text and its label run.
5234
- *
5235
- * The delimiter has to sit at the label's edge: immediately AFTER it ("Label:
5236
- * sentence"), or at its own tail ("1.7. Records. " — a numbered clause whose
5237
- * marker and label share one run). That adjacency is what distinguishes a label
5238
- * from an ordinary emphasised opening clause.
5239
- */
5240
- function leadInShapeOf(blockText, labelText, quote) {
5241
- if (quote)
5242
- return { kind: 'quoted', quote };
5243
- const after = LEAD_IN_DELIMITERS.find((d) => blockText.startsWith(d, labelText.length));
5244
- if (after)
5245
- return { kind: 'before', delimiter: after };
5246
- const trimmed = labelText.trimEnd();
5247
- const inside = LEAD_IN_TAIL_DELIMITERS.find((d) => trimmed.endsWith(d.trimEnd()) && trimmed.length > d.trimEnd().length);
5248
- if (inside) {
5249
- const occurrence = labelText.split(inside).length - 1;
5250
- if (occurrence > 0)
5251
- return { kind: 'inside', delimiter: inside, occurrence };
5252
- }
5253
- // A single emphasised word followed by a space and plain prose: the token
5254
- // itself is the label ("WHEREAS the Party…"). Multi-word runs stay
5255
- // unrecognised — an emphasised opening clause is prose, not a label.
5256
- if (/^\S+\s$/.test(labelText) && blockText.length > labelText.length && blockText.startsWith(labelText)) {
5257
- return { kind: 'word', label: labelText.trimEnd() };
5258
- }
5259
- return null;
5260
- }
5261
- /** The span of `text` that carries the label look, or null when it has none. */
5262
5193
  function leadInSpanOf(text, shape) {
5263
- if (shape.kind === 'typed')
5264
- return typedCaptionSpanOf(text, shape.delimiter);
5265
- if (shape.kind === 'word') {
5266
- // Only the SAME token inherits the look — "WHEREAS the Parties…" does,
5267
- // "The Parties further agree…" does not (no guessing a label there).
5268
- const next = text.charAt(shape.label.length);
5269
- return text.startsWith(shape.label) && (next === '' || /\s/.test(next))
5270
- ? { start: 0, end: shape.label.length }
5271
- : null;
5272
- }
5273
- if (shape.kind === 'quoted') {
5274
- const opener = text.slice(0, 2).indexOf(shape.quote);
5275
- if (opener < 0)
5276
- return null;
5277
- const close = text.indexOf(shape.quote, opener + 1);
5278
- if (close < 0 || close - opener - 1 <= 0 || close > LEAD_IN_MAX_CHARS)
5279
- return null;
5280
- return { start: opener + 1, end: close };
5281
- }
5282
- if (shape.kind === 'inside') {
5283
- // Mirror the reference's STRUCTURE: "1.7. Records. " ends at its second
5284
- // ". ", so "1.8. Review. The parties…" must end at its second one too —
5285
- // stopping at the first would emphasise the number alone.
5286
- let at = -1;
5287
- for (let seen = 0; seen < shape.occurrence; seen += 1) {
5288
- at = text.indexOf(shape.delimiter, at + 1);
5289
- if (at < 0)
5290
- return null;
5291
- }
5292
- if (at <= 0 || at > LEAD_IN_MAX_CHARS)
5293
- return null;
5294
- return { start: 0, end: at + shape.delimiter.length };
5295
- }
5296
- const end = leadInEndOf(text, shape.delimiter);
5297
- return end == null ? null : { start: 0, end };
5194
+ return shape.kind === 'typed' ? typedCaptionSpanOf(text, shape.delimiter) : leadInLabel.labelLeadInSpanOf(text, shape);
5298
5195
  }
5299
5196
  const TYPED_MARKER_CORE = String.raw `(?:\d{1,3}(?:\.\d{1,3})*|[ivx]{1,6}|[IVX]{1,6}|[a-z]{1,2}|[A-Z])`;
5300
5197
  const TYPED_MARKER_TOKEN = String.raw `(?:\(${TYPED_MARKER_CORE}\)|${TYPED_MARKER_CORE}[.)])`;
@@ -5454,11 +5351,11 @@ async function listItemRunPattern(doc, text, blockId) {
5454
5351
  return null;
5455
5352
  // Window: through the label's delimiter plus enough of the sentence to sample
5456
5353
  // it, or a plain head when the item carries no label at all.
5457
- const delimiterAt = leadInEndOf(text);
5458
- let windowEnd = delimiterAt != null ? delimiterAt + LEAD_IN_BODY_SAMPLE_CHARS : LEAD_IN_BODY_SAMPLE_CHARS;
5354
+ const delimiterAt = leadInLabel.leadInEndOf(text);
5355
+ let windowEnd = delimiterAt != null ? delimiterAt + leadInLabel.LEAD_IN_BODY_SAMPLE_CHARS : leadInLabel.LEAD_IN_BODY_SAMPLE_CHARS;
5459
5356
  const typedCaption = typedCaptionSpanOf(text);
5460
5357
  if (typedCaption)
5461
- windowEnd = Math.max(windowEnd, typedCaption.end + LEAD_IN_BODY_SAMPLE_CHARS);
5358
+ windowEnd = Math.max(windowEnd, typedCaption.end + leadInLabel.LEAD_IN_BODY_SAMPLE_CHARS);
5462
5359
  const pattern = sliceWholeChars(text, windowEnd);
5463
5360
  const result = asRecord(await queryFn({ select: { type: 'text', pattern } }));
5464
5361
  const items = Array.isArray(result?.items) ? result.items : [];
@@ -5518,7 +5415,7 @@ function runPatternFromRuns(runs, blockText, runTextOf) {
5518
5415
  // `"` + **Deliverables** + `" means …` — so the label is the SECOND run
5519
5416
  // and the quotes stay unemphasised.
5520
5417
  const opener = runTextOf(runs[0]).trim();
5521
- const quote = LEAD_IN_QUOTES.find((q) => opener === q);
5418
+ const quote = leadInLabel.LEAD_IN_QUOTES.find((q) => opener === q);
5522
5419
  const labelIndex = quote && runs.length > 2 ? 1 : 0;
5523
5420
  const label = runs[labelIndex];
5524
5421
  const labelText = runTextOf(label);
@@ -5530,8 +5427,8 @@ function runPatternFromRuns(runs, blockText, runTextOf) {
5530
5427
  // would stamp its emphasis across the whole new block.
5531
5428
  const bodyStyle = matchRunStyle(runs[labelIndex + 1]);
5532
5429
  const differs = !sameMatchRunStyle(leadStyle, bodyStyle);
5533
- const shape = leadInShapeOf(blockText, labelText, labelIndex > 0 ? quote : undefined);
5534
- const isLabel = differs && shape != null && labelText.length > 0 && labelText.length <= LEAD_IN_MAX_CHARS;
5430
+ const shape = leadInLabel.leadInShapeOf(blockText, labelText, labelIndex > 0 ? quote : undefined);
5431
+ const isLabel = differs && shape != null && labelText.length > 0 && labelText.length <= leadInLabel.LEAD_IN_MAX_CHARS;
5535
5432
  return runPatternOf(bodyStyle, isLabel && shape ? { style: leadStyle, shape } : undefined);
5536
5433
  }
5537
5434
  function typedPrefixLabelOf(runs, runTextOf) {
@@ -5571,9 +5468,9 @@ function typedPrefixLabelOf(runs, runTextOf) {
5571
5468
  if (bodyIndex >= runs.length)
5572
5469
  return null;
5573
5470
  const labelText = runs.slice(labelIndex, bodyIndex).map(runTextOf).join('').trim();
5574
- if (labelText.length === 0 || labelText.length > LEAD_IN_MAX_CHARS)
5471
+ if (labelText.length === 0 || labelText.length > leadInLabel.LEAD_IN_MAX_CHARS)
5575
5472
  return null;
5576
- const delimiter = ['.', ...LEAD_IN_DELIMITERS.map((d) => d.trim())].find((d) => labelText.endsWith(d) && labelText.length > d.length);
5473
+ const delimiter = ['.', ...leadInLabel.LEAD_IN_DELIMITERS.map((d) => d.trim())].find((d) => labelText.endsWith(d) && labelText.length > d.length);
5577
5474
  if (!delimiter)
5578
5475
  return null;
5579
5476
  return runPatternOf(matchRunStyle(runs[bodyIndex]), {
@@ -5611,7 +5508,7 @@ function paragraphIndentOf(payload) {
5611
5508
  return null;
5612
5509
  }
5613
5510
  function emphasisedLookOf(look) {
5614
- return RUN_BOOL_MARKS.some((key) => look[key] === true) ? look : null;
5511
+ return insertedBlockFormatting.RUN_BOOL_MARKS.some((key) => look[key] === true) ? look : null;
5615
5512
  }
5616
5513
  async function effectiveBaseLookOf(doc, text, block) {
5617
5514
  const queryFn = maybeMethod(doc, ['query', 'match']);
@@ -5661,7 +5558,7 @@ function inlineLookFromRow(row) {
5661
5558
  */
5662
5559
  function runInlineAgainstBaseline(marks, baseline) {
5663
5560
  const inline = marksToInline(marks);
5664
- for (const key of RUN_BOOL_MARKS) {
5561
+ for (const key of insertedBlockFormatting.RUN_BOOL_MARKS) {
5665
5562
  if (baseline[key] === true && inline[key] === undefined)
5666
5563
  inline[key] = false;
5667
5564
  }
@@ -6567,26 +6464,12 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
6567
6464
  applied.indent = refIndent;
6568
6465
  }
6569
6466
  }
6570
- // Lead-marker pattern: manually lettered/numbered sections style the
6571
- // marker run differently from the body ("(m) " bold, rest plain) — a
6572
- // per-run pattern blocks.list rows cannot express. When the reference
6573
- // block opens with such a styled marker and the created text opens with a
6574
- // similar marker token, reproduce the reference's marker/body split on the
6575
- // created block. Skipped when the caller supplied explicit runs/marks.
6576
6467
  if (allowLeadPattern) {
6577
6468
  const lead = await matchLeadMarkerPattern(doc, reference, created, createdText);
6578
6469
  if (lead?.changed)
6579
6470
  applied.leadMarker = lead;
6580
- // matchLeadMarkerPattern only knows a shared literal lead ("WHEREAS") and
6581
- // numbered tokens ("1.", "(m)"), and it needs the reference split across
6582
- // two runs. Sample the reference's RUNS for everything else: a quoted
6583
- // defined term (`"Deliverables" means …`), a "1.7. Records. " clause whose
6584
- // marker and label share a run. A UNIFORM reference is not a split, so
6585
- // its emphasis never transfers.
6586
6471
  else {
6587
6472
  const runPattern = await matchBlockRunPattern(doc, reference, created, createdText);
6588
- // Assigning `undefined` would still create the key, and an `applied` with
6589
- // keys reads as "this block was matched" — hiding a block that got nothing.
6590
6473
  if (runPattern)
6591
6474
  applied.runPattern = runPattern;
6592
6475
  }
@@ -6636,21 +6519,6 @@ function typedShapeReference(postBlocks, preIds, created, createdIndex, createdT
6636
6519
  nearest((rowShape) => markerShapeFits(rowShape, shape)) ??
6637
6520
  reference);
6638
6521
  }
6639
- /** A lead-in marker token like "(m)", "1.", "a)", "(iv)" at the start of clause text. */
6640
- const LEAD_MARKER_PATTERN = /^\(?(?:[a-zA-Z]{1,3}|\d{1,3})[).:\]]/;
6641
- /**
6642
- * Reproduce a reference block's lead/body run split on a freshly-created
6643
- * block. Fires only when the reference genuinely has the pattern — its first
6644
- * run is a SHORT lead-in styled differently from the body — and the created
6645
- * text opens with a matching lead of its own. Two lead shapes are recognized:
6646
- * - literal lead words shared by siblings ("WHEREAS", "NOW, THEREFORE"): the
6647
- * created text starts with the reference's exact lead text;
6648
- * - marker tokens ("(m)", "1.", "a)"): the token text differs per clause, so
6649
- * the created lead is its OWN token matched by shape.
6650
- * Applies the reference lead's marks to the created lead span and only the
6651
- * reference body's font and size to the rest: the body never gains emphasis or
6652
- * an explicit mark-off it did not already carry.
6653
- */
6654
6522
  async function matchLeadMarkerPattern(doc, reference, created, createdText) {
6655
6523
  const createdPreview = created.textPreview ?? '';
6656
6524
  if (!reference.nodeId || !created.nodeId || !createdPreview)
@@ -6659,60 +6527,20 @@ async function matchLeadMarkerPattern(doc, reference, created, createdText) {
6659
6527
  const formatFn = maybeMethod(doc, ['format', 'apply']);
6660
6528
  if (!queryFn || !formatFn)
6661
6529
  return null;
6662
- // A short unique-enough prefix of the reference is all we need: the runs
6663
- // returned tile the MATCHED range, covering the lead and the body start.
6664
- // blocks.list truncates textPreview with a trailing ellipsis — strip it, the
6665
- // remaining prefix IS document text so query.match can find it.
6666
- const refProbe = (reference.textPreview ?? '')
6667
- .replace(/…\s*$/, '')
6668
- .slice(0, 60)
6669
- .trim();
6530
+ const refProbe = insertedBlockFormatting.referenceProbeOf(reference.textPreview ?? '');
6670
6531
  if (!refProbe)
6671
6532
  return null;
6672
- const refRuns = await docSnapshot.matchRunsForBlock(queryFn, refProbe, reference.nodeId);
6673
- if (refRuns.length < 2)
6674
- return null; // uniform reference — no split to copy
6675
- const leadRun = refRuns[0];
6676
- const bodyRun = refRuns[1];
6677
- // The pattern requires a real FORMATTING transition between lead and body
6678
- // (bold/italic/underline/strike). Runs split only by font/color/hyperlink
6679
- // boundaries are ordinary prose — restyling from those would misfire.
6680
- if (RUN_BOOL_MARKS.every((key) => Boolean(leadRun[key]) === Boolean(bodyRun[key])))
6681
- return null;
6682
- const refLead = leadRun.text.trimEnd();
6683
- // The first run must be a short lead-in (a token or a word or two), not
6684
- // half the sentence.
6685
- if (refLead.length === 0 || refLead.length > 40 || leadRun.start !== 0)
6533
+ const split = insertedBlockFormatting.leadBodySplitOf(await docSnapshot.matchRunsForBlock(queryFn, refProbe, reference.nodeId));
6534
+ if (!split)
6686
6535
  return null;
6687
- // The caller knows the text it inserted; the blocks.list preview is truncated.
6688
- const fullText = createdText || createdPreview.replace(/…\s*$/, '');
6689
- // The created block's own lead span (ASCII markers only by design).
6690
- let createdLeadLen = 0;
6691
- const boundaryAfter = (len) => len >= fullText.length || !/[A-Za-z0-9]/.test(fullText[len]);
6692
- if (fullText.startsWith(refLead) && boundaryAfter(refLead.length)) {
6693
- // Literal shared lead word(s) — "WHEREAS, …" after a "WHEREAS, …" sibling.
6694
- // Exact-case with a token boundary so "Note" never claims "Notebook".
6695
- createdLeadLen = refLead.length;
6696
- }
6697
- else {
6698
- const refMarker = LEAD_MARKER_PATTERN.exec(refLead);
6699
- const createdMarker = LEAD_MARKER_PATTERN.exec(fullText);
6700
- // Marker tokens: the reference's first run must BE the token, and the
6701
- // created text must open with a token of the same category (letters
6702
- // with letters, digits with digits — "(m)" doesn't restyle "1.").
6703
- if (refMarker &&
6704
- createdMarker &&
6705
- refMarker[0] === refLead &&
6706
- /\d/.test(refMarker[0]) === /\d/.test(createdMarker[0])) {
6707
- createdLeadLen = createdMarker[0].length;
6708
- }
6709
- }
6536
+ const fullText = createdText || insertedBlockFormatting.withoutTruncationEllipsis(createdPreview);
6537
+ const createdLeadLen = insertedBlockFormatting.createdLeadLengthOf(split.leadText, fullText);
6710
6538
  if (createdLeadLen === 0)
6711
6539
  return null;
6712
- const markerInline = emphasisedLookOf(inlineFromSnapshotRun(leadRun));
6540
+ const markerInline = emphasisedLookOf(inlineFromSnapshotRun(split.lead));
6713
6541
  if (!markerInline)
6714
6542
  return null;
6715
- const bodyInline = baseRunLookOf(bodyRun);
6543
+ const bodyInline = baseRunLookOf(split.body);
6716
6544
  let changed = await applyInlineQuiet(formatFn, {
6717
6545
  blockId: created.nodeId,
6718
6546
  start: 0,
@@ -6730,21 +6558,10 @@ async function matchLeadMarkerPattern(doc, reference, created, createdText) {
6730
6558
  }
6731
6559
  return { marker: fullText.slice(0, createdLeadLen), markerInline, bodyInline, changed };
6732
6560
  }
6733
- /**
6734
- * Copy a reference block's RUN pattern onto a created block: the body's font
6735
- * and size over the whole block, then the label look over the new block's own
6736
- * label span.
6737
- *
6738
- * Complements matchLeadMarkerPattern, which requires either a shared literal
6739
- * lead word or a marker token AND a two-run reference. This one reads the
6740
- * reference's runs (`query.match`, the only read that reports run styling) and
6741
- * handles the shapes that leaves out. A single uniform run has no label, so
6742
- * nothing but its font transfers.
6743
- */
6744
6561
  async function matchBlockRunPattern(doc, reference, created, createdText) {
6745
6562
  const formatFn = maybeMethod(doc, ['format', 'apply']);
6746
- const referenceText = (reference.textPreview ?? '').replace(/…\s*$/, '').trim();
6747
- const fullText = createdText || (created.textPreview ?? '').replace(/…\s*$/, '');
6563
+ const referenceText = insertedBlockFormatting.withoutTruncationEllipsis(reference.textPreview ?? '').trim();
6564
+ const fullText = createdText || insertedBlockFormatting.withoutTruncationEllipsis(created.textPreview ?? '');
6748
6565
  if (!formatFn || !reference.nodeId || !created.nodeId || !referenceText || !fullText)
6749
6566
  return null;
6750
6567
  const pattern = await listItemRunPattern(doc, referenceText, reference.nodeId);
@@ -6756,11 +6573,7 @@ async function matchBlockRunPattern(doc, reference, created, createdText) {
6756
6573
  const own = Object.keys(sampled).length > 0
6757
6574
  ? await effectiveBaseLookOf(doc, fullText, { nodeId: created.nodeId, nodeType: created.nodeType })
6758
6575
  : null;
6759
- const body = own === 'mixed'
6760
- ? {}
6761
- : own
6762
- ? Object.fromEntries(Object.entries(sampled).filter(([key, value]) => own[key] !== value))
6763
- : sampled;
6576
+ const body = insertedBlockFormatting.bodyLookToWrite(sampled, own);
6764
6577
  if (Object.keys(body).length > 0) {
6765
6578
  if (await applyInlineQuiet(formatFn, { blockId: created.nodeId, start: 0, end, inline: body })) {
6766
6579
  applied.body = body;
@@ -6786,17 +6599,10 @@ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args, avail
6786
6599
  .filter(({ row }) => row.nodeId && !preIds.has(row.nodeId) && !row.isEmpty);
6787
6600
  if (createdRows.length === 0)
6788
6601
  return receipt;
6789
- // The placement anchor, when the action targeted one, is the user's own
6790
- // statement of "blend in with THIS" — prefer it over type-matched siblings.
6791
- const placement = guards.isRecord(args?.placement) ? args.placement : null;
6792
- const anchorSelector = placement && guards.isRecord(placement.selector) ? placement.selector : null;
6793
- const anchorId = anchorSelector && typeof anchorSelector.nodeId === 'string' ? anchorSelector.nodeId : null;
6602
+ const anchorId = insertedBlockFormatting.placementAnchorIdOf(args);
6794
6603
  const anchorRow = anchorId ? (postBlocks.find((b) => b.nodeId === anchorId) ?? null) : null;
6795
- // When the caller styled any entry explicitly (runs/marks), it took
6796
- // ownership of intra-paragraph formatting — the automatic lead-marker
6797
- // pattern pass must not fight it.
6798
- const rawTexts = Array.isArray(args?.texts) ? args.texts : args?.text != null ? [args.text] : [];
6799
- const hasExplicitStyling = rawTexts.some((entry) => guards.isRecord(entry) && (Array.isArray(entry.runs) || entry.marks != null));
6604
+ const rawTexts = insertedBlockFormatting.insertedTextEntriesOf(args);
6605
+ const hasExplicitStyling = insertedBlockFormatting.callerStyledAnEntry(rawTexts);
6800
6606
  const insertedTexts = rawTexts.map((entry) => {
6801
6607
  if (typeof entry === 'string')
6802
6608
  return entry;
@@ -6804,26 +6610,12 @@ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args, avail
6804
6610
  return spec ? itemSpecText(spec) : '';
6805
6611
  });
6806
6612
  const allApplied = [];
6807
- for (let ci = 0; ci < createdRows.length; ci += 1) {
6808
- const { row: created, index: createdIndex } = createdRows[ci];
6809
- // The first of several inserted blocks is the section TITLE — it should
6810
- // read like a heading (flush-left), so it must NOT inherit the body
6811
- // sibling's left indent even though it matches the same anchor.
6812
- const isTitle = createdRows.length > 1 && ci === 0;
6613
+ for (const [ci, { row: created, index: createdIndex }] of createdRows.entries()) {
6813
6614
  const createdText = insertedTexts[ci] ?? '';
6814
- const match = (anchor) => matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anchor, createdRows.length === 1, isTitle, !hasExplicitStyling,
6815
- // The texts the caller inserted, in order — the truncated preview
6816
- // cannot measure a label span or the end of the block.
6817
- createdText);
6615
+ const match = (anchor) => matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anchor, createdRows.length === 1, insertedBlockFormatting.isSectionTitle(ci, createdRows.length), !hasExplicitStyling, createdText);
6818
6616
  let result = await match(anchorRow);
6819
- // A GROUP of paragraphs usually repeats the group before it: a signature
6820
- // block is name / "By:" / "Name:", and the new name line should look like
6821
- // the previous NAME line, not like the "Name:" line directly above it.
6822
- // Only tried when matching against the neighbour found nothing, so this
6823
- // cannot change a case that already had an answer.
6824
- const applied = guards.isRecord(result?.applied) ? result.applied : null;
6825
- if (createdRows.length > 1 && (!applied || Object.keys(applied).length === 0)) {
6826
- const parallel = postBlocks[createdIndex - createdRows.length];
6617
+ if (insertedBlockFormatting.shouldTryParallelGroup(createdRows.length, result)) {
6618
+ const parallel = postBlocks[insertedBlockFormatting.parallelGroupIndexOf(createdIndex, createdRows.length)];
6827
6619
  if (parallel && preIds.has(parallel.nodeId) && !parallel.isEmpty) {
6828
6620
  result = (await match(parallel)) ?? result;
6829
6621
  }
@@ -6831,13 +6623,12 @@ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args, avail
6831
6623
  if (result)
6832
6624
  allApplied.push(result);
6833
6625
  }
6834
- if (allApplied.length === 0)
6626
+ const contextualFormatting = insertedBlockFormatting.contextualFormattingOf(allApplied);
6627
+ if (!contextualFormatting)
6835
6628
  return receipt;
6836
- return { ...receipt, contextualFormatting: allApplied.length === 1 ? allApplied[0] : allApplied };
6629
+ return { ...receipt, contextualFormatting };
6837
6630
  }
6838
6631
  catch (err) {
6839
- // Formatting matching is best-effort polish: never fail a successful
6840
- // insert because of it — but the receipt says it was skipped.
6841
6632
  return {
6842
6633
  ...receipt,
6843
6634
  contextualFormatting: { skipped: reasonOf(err) },