@stll/folio-core 0.15.12 → 0.15.13

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.
@@ -42,6 +42,10 @@ const applyReplaceBlockStyleId = ({ item, tr }) => {
42
42
  styleId: item.operation.styleId
43
43
  });
44
44
  };
45
+ const REPLACEMENT_BACKGROUND_CLEAR_FORMATTING = {
46
+ highlight: false,
47
+ runShading: false
48
+ };
45
49
  const applyInlineFormatting = ({ tr, schema, from, to, formatting }) => {
46
50
  for (const [name, enabled] of Object.entries(formatting)) {
47
51
  const markType = schema.marks[name];
@@ -60,6 +64,29 @@ const formattingWouldChange = (marks, formatting) => Object.entries(formatting).
60
64
  if (name === "underline") return mark?.attrs["style"] !== "single";
61
65
  return mark === void 0;
62
66
  });
67
+ const clearReplacementBackground = ({ tr, schema, from, to, mode, revisionId, author, date, initials, suggestionId = null }) => {
68
+ if (![schema.marks["highlight"], schema.marks["runShading"]].some((markType) => markType !== void 0 && tr.doc.rangeHasMark(from, to, markType))) return tr;
69
+ if (mode === "direct") return applyInlineFormatting({
70
+ tr,
71
+ schema,
72
+ from,
73
+ to,
74
+ formatting: REPLACEMENT_BACKGROUND_CLEAR_FORMATTING
75
+ });
76
+ return applyTrackedInlineFormatting({
77
+ tr,
78
+ schema,
79
+ doc: tr.doc,
80
+ from,
81
+ to,
82
+ formatting: REPLACEMENT_BACKGROUND_CLEAR_FORMATTING,
83
+ revisionId,
84
+ author,
85
+ date,
86
+ initials,
87
+ suggestionId
88
+ });
89
+ };
63
90
  const applyTrackedInlineFormatting = ({ tr, schema, doc, from, to, formatting, revisionId, author, date, initials, suggestionId = null }) => {
64
91
  const propertyChangeType = schema.marks["runPropertyChange"];
65
92
  if (!propertyChangeType) return tr;
@@ -418,6 +445,21 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
418
445
  case "replaceRange": {
419
446
  const revisionIdDelete = revisionSeed++;
420
447
  const revisionIdInsert = revisionSeed++;
448
+ const revisionIdBackground = revisionSeed++;
449
+ const stepsBeforeBackgroundClear = tr.steps.length;
450
+ tr = clearReplacementBackground({
451
+ tr,
452
+ schema: view.state.schema,
453
+ from: item.from,
454
+ to: item.to,
455
+ mode,
456
+ revisionId: revisionIdBackground,
457
+ author,
458
+ date,
459
+ initials,
460
+ suggestionId
461
+ });
462
+ const clearedBackground = tr.steps.length > stepsBeforeBackgroundClear;
421
463
  tr = applyTextReplacement({
422
464
  tr,
423
465
  item,
@@ -430,7 +472,11 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
430
472
  suggestionId,
431
473
  initials
432
474
  });
433
- if (producesTrackedChanges) appliedRevisionIds = [revisionIdDelete, revisionIdInsert];
475
+ if (producesTrackedChanges) appliedRevisionIds = [
476
+ revisionIdDelete,
477
+ revisionIdInsert,
478
+ ...clearedBackground ? [revisionIdBackground] : []
479
+ ];
434
480
  break;
435
481
  }
436
482
  case "commentOnRange":
@@ -466,6 +512,7 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
466
512
  case "replaceBlock": {
467
513
  const revisionIdDelete = revisionSeed++;
468
514
  const revisionIdInsert = revisionSeed++;
515
+ const revisionIdBackground = revisionSeed++;
469
516
  if (item.operation.preserveFormatting === false && mode === "direct") {
470
517
  const replacement = item.operation.text;
471
518
  const paragraphType = view.state.schema.nodes["paragraph"];
@@ -485,6 +532,20 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
485
532
  tr = tr.replaceWith(item.blockFrom, item.blockTo, node);
486
533
  break;
487
534
  }
535
+ const stepsBeforeBackgroundClear = tr.steps.length;
536
+ tr = clearReplacementBackground({
537
+ tr,
538
+ schema: view.state.schema,
539
+ from: item.from,
540
+ to: item.to,
541
+ mode,
542
+ revisionId: revisionIdBackground,
543
+ author,
544
+ date,
545
+ initials,
546
+ suggestionId
547
+ });
548
+ const clearedBackground = tr.steps.length > stepsBeforeBackgroundClear;
488
549
  tr = applyTextReplacement({
489
550
  tr,
490
551
  item,
@@ -501,7 +562,11 @@ const applyFolioAIEditOperationsInternal = ({ view, snapshot, operations, mode =
501
562
  item,
502
563
  tr
503
564
  });
504
- if (producesTrackedChanges) appliedRevisionIds = [revisionIdDelete, revisionIdInsert];
565
+ if (producesTrackedChanges) appliedRevisionIds = [
566
+ revisionIdDelete,
567
+ revisionIdInsert,
568
+ ...clearedBackground ? [revisionIdBackground] : []
569
+ ];
505
570
  break;
506
571
  }
507
572
  case "insertAfterBlock":
@@ -69,7 +69,7 @@ function formatNumberedMarker(counters, level) {
69
69
  const parts = [];
70
70
  for (let i = 0; i <= level; i += 1) {
71
71
  const value = counters[i] ?? 0;
72
- if (value <= 0) break;
72
+ if (!Number.isFinite(value) || value <= 0) break;
73
73
  parts.push(value);
74
74
  }
75
75
  if (parts.length === 0) return "1.";
@@ -156,23 +156,23 @@ function computeListMarker(pmAttrs, listCounters, abstractCounters, seenNumIds)
156
156
  const abstractNumId = pmAttrs.listAbstractNumId;
157
157
  if (level > 0) {
158
158
  const latestAbstractCounters = abstractNumId === void 0 ? void 0 : abstractCounters.get(abstractNumId);
159
- if (counters.slice(0, level).every(Number.isNaN)) for (let i = 0; i < level; i += 1) {
159
+ if (counters.slice(0, level).every((counter) => !Number.isFinite(counter))) for (let i = 0; i < level; i += 1) {
160
160
  const latestCounter = latestAbstractCounters?.[i];
161
- counters[i] = latestCounter !== void 0 && !Number.isNaN(latestCounter) ? latestCounter : pmAttrs.listLevelStarts?.[i] ?? 1;
161
+ counters[i] = latestCounter !== void 0 && Number.isFinite(latestCounter) ? latestCounter : pmAttrs.listLevelStarts?.[i] ?? 1;
162
162
  }
163
163
  }
164
164
  const seenKey = `${numId}:${level}`;
165
165
  if (!seenNumIds.has(seenKey)) {
166
166
  seenNumIds.add(seenKey);
167
167
  if (pmAttrs.listStartOverride != null) counters[level] = pmAttrs.listStartOverride - 1;
168
- else if (Number.isNaN(counters[level])) counters[level] = (pmAttrs.listLevelStarts?.[level] ?? 1) - 1;
169
168
  }
169
+ if (!Number.isFinite(counters[level])) counters[level] = (pmAttrs.listLevelStarts?.[level] ?? 1) - 1;
170
170
  counters[level] = (counters[level] ?? 0) + 1;
171
171
  for (let i = level + 1; i < counters.length; i += 1) counters[i] = NaN;
172
172
  const childAdvances = pmAttrs.listImplicitChildLevelAdvances ?? 0;
173
173
  if (childAdvances > 0 && level + 1 < counters.length) {
174
174
  const childCounter = counters[level + 1];
175
- counters[level + 1] = (childCounter === void 0 || Number.isNaN(childCounter) ? 0 : childCounter) + childAdvances;
175
+ counters[level + 1] = (childCounter === void 0 || !Number.isFinite(childCounter) ? 0 : childCounter) + childAdvances;
176
176
  }
177
177
  listCounters.set(numId, counters);
178
178
  if (abstractNumId !== void 0) abstractCounters.set(abstractNumId, [...counters]);
@@ -75,7 +75,17 @@ type CollapsedLineEdgeCaretGeometry = {
75
75
  * below the line until visible text is typed. Anchor the caret to the nearest
76
76
  * painted sibling instead; an all-whitespace line falls back to its line box.
77
77
  */
78
- declare function getCollapsedLineEdgeCaretGeometry(spanEl: HTMLElement): CollapsedLineEdgeCaretGeometry | null;
78
+ declare function getCollapsedLineEdgeCaretGeometry(spanEl: HTMLElement, pmPos?: number): CollapsedLineEdgeCaretGeometry | null;
79
+ type CollapsedLineEdgeCaretTarget = {
80
+ span: HTMLElement;
81
+ geometry: CollapsedLineEdgeCaretGeometry;
82
+ };
83
+ /**
84
+ * Prefer a collapsed line-edge span when multiple painted spans share the PM
85
+ * position. Generic text runs use inclusive endpoints, so DOM order alone
86
+ * cannot decide which span owns a boundary.
87
+ */
88
+ declare function findCollapsedLineEdgeCaretTarget(spans: readonly HTMLElement[], pmPos: number): CollapsedLineEdgeCaretTarget | null;
79
89
  declare function getCaretPositionFromDom(container: HTMLElement, pmPos: number, overlayRect: DOMRect): DomCaretPosition | null;
80
90
  //#endregion
81
- export { CollapsedLineEdgeCaretGeometry, DomCaretPosition, DomSelectionRect, clickToPositionDom, findPositionInSpan, getCaretPositionFromDom, getCollapsedLineEdgeCaretGeometry, getSelectionRectsFromDom };
91
+ export { CollapsedLineEdgeCaretGeometry, CollapsedLineEdgeCaretTarget, DomCaretPosition, DomSelectionRect, clickToPositionDom, findCollapsedLineEdgeCaretTarget, findPositionInSpan, getCaretPositionFromDom, getCollapsedLineEdgeCaretGeometry, getSelectionRectsFromDom };
@@ -232,7 +232,7 @@ function getSelectionRectsFromDom(container, from, to, overlayRect) {
232
232
  * below the line until visible text is typed. Anchor the caret to the nearest
233
233
  * painted sibling instead; an all-whitespace line falls back to its line box.
234
234
  */
235
- function getCollapsedLineEdgeCaretGeometry(spanEl) {
235
+ function getCollapsedLineEdgeCaretGeometry(spanEl, pmPos) {
236
236
  const isLeading = spanEl.dataset["collapsedLeadingSpaces"] === "true";
237
237
  const isTrailing = spanEl.dataset["collapsedTrailingSpaces"] === "true";
238
238
  if (!isLeading && !isTrailing) return null;
@@ -242,14 +242,50 @@ function getCollapsedLineEdgeCaretGeometry(spanEl) {
242
242
  const line = closestHtmlElement(spanEl, ".layout-line");
243
243
  const anchorRect = sibling?.getBoundingClientRect() ?? line?.getBoundingClientRect() ?? spanRect;
244
244
  const lineRect = line?.getBoundingClientRect();
245
+ const pmStart = Number(spanEl.dataset["pmStart"]);
246
+ const pmEnd = Number(spanEl.dataset["pmEnd"]);
247
+ const storedSpaceAdvance = Number(spanEl.dataset["collapsedSpaceAdvance"]);
248
+ const lineScale = line && line.offsetWidth > 0 && lineRect ? lineRect.width / line.offsetWidth : 1;
249
+ const trailingSpaceCount = isTrailing && pmPos !== void 0 && Number.isFinite(pmStart) && Number.isFinite(pmEnd) ? Math.max(0, Math.min(pmPos, pmEnd) - pmStart) : 0;
250
+ const inlineDirection = spanRect.left <= anchorRect.left ? -1 : 1;
251
+ const trailingAdvance = Number.isFinite(storedSpaceAdvance) && lineScale > 0 ? trailingSpaceCount * storedSpaceAdvance * lineScale * inlineDirection : 0;
245
252
  return {
246
- left: spanRect.left,
253
+ left: spanRect.left + trailingAdvance,
247
254
  top: anchorRect.top,
248
255
  height: anchorRect.height || lineRect?.height || 16
249
256
  };
250
257
  }
258
+ /**
259
+ * Prefer a collapsed line-edge span when multiple painted spans share the PM
260
+ * position. Generic text runs use inclusive endpoints, so DOM order alone
261
+ * cannot decide which span owns a boundary.
262
+ */
263
+ function findCollapsedLineEdgeCaretTarget(spans, pmPos) {
264
+ for (const span of spans) {
265
+ const pmStart = Number(span.dataset["pmStart"]);
266
+ const pmEnd = Number(span.dataset["pmEnd"]);
267
+ if (pmPos < pmStart || pmPos > pmEnd) continue;
268
+ const geometry = getCollapsedLineEdgeCaretGeometry(span, pmPos);
269
+ if (geometry) return {
270
+ span,
271
+ geometry
272
+ };
273
+ }
274
+ return null;
275
+ }
251
276
  function getCaretPositionFromDom(container, pmPos, overlayRect) {
252
277
  const spans = htmlQueryAll(container, ".layout-page-content span[data-pm-start][data-pm-end]");
278
+ const collapsedTarget = findCollapsedLineEdgeCaretTarget(spans, pmPos);
279
+ if (collapsedTarget) {
280
+ const pageEl = closestHtmlElement(collapsedTarget.span, ".layout-page");
281
+ const pageIndex = pageEl ? Number(pageEl.dataset["pageNumber"] || 1) - 1 : 0;
282
+ return {
283
+ x: collapsedTarget.geometry.left - overlayRect.left,
284
+ y: collapsedTarget.geometry.top - overlayRect.top,
285
+ height: collapsedTarget.geometry.height,
286
+ pageIndex
287
+ };
288
+ }
253
289
  for (const spanEl of spans) {
254
290
  const pmStart = Number(spanEl.dataset["pmStart"]);
255
291
  const pmEnd = Number(spanEl.dataset["pmEnd"]);
@@ -270,17 +306,6 @@ function getCaretPositionFromDom(container, pmPos, overlayRect) {
270
306
  continue;
271
307
  }
272
308
  if (pmPos >= pmStart && pmPos <= pmEnd) {
273
- const collapsedGeometry = getCollapsedLineEdgeCaretGeometry(spanEl);
274
- if (collapsedGeometry) {
275
- const pageEl = closestHtmlElement(spanEl, ".layout-page");
276
- const pageIndex = pageEl ? Number(pageEl.dataset["pageNumber"] || 1) - 1 : 0;
277
- return {
278
- x: collapsedGeometry.left - overlayRect.left,
279
- y: collapsedGeometry.top - overlayRect.top,
280
- height: collapsedGeometry.height,
281
- pageIndex
282
- };
283
- }
284
309
  const textNode = spanEl.firstChild;
285
310
  if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
286
311
  const spanRect = spanEl.getBoundingClientRect();
@@ -335,4 +360,4 @@ function getCaretPositionFromDom(container, pmPos, overlayRect) {
335
360
  return null;
336
361
  }
337
362
  //#endregion
338
- export { clickToPositionDom, findPositionInSpan, getCaretPositionFromDom, getCollapsedLineEdgeCaretGeometry, getSelectionRectsFromDom };
363
+ export { clickToPositionDom, findCollapsedLineEdgeCaretTarget, findPositionInSpan, getCaretPositionFromDom, getCollapsedLineEdgeCaretGeometry, getSelectionRectsFromDom };
@@ -69,7 +69,7 @@ function computeHfCaretRectFromView(view, section, doc = globalThis.document) {
69
69
  const end = Number(span.dataset["pmEnd"]);
70
70
  if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
71
71
  if (pmPos >= start && pmPos <= end) {
72
- const collapsedGeometry = getCollapsedLineEdgeCaretGeometry(span);
72
+ const collapsedGeometry = getCollapsedLineEdgeCaretGeometry(span, pmPos);
73
73
  if (collapsedGeometry) return {
74
74
  top: collapsedGeometry.top,
75
75
  left: collapsedGeometry.left,
@@ -988,11 +988,14 @@ function renderLine(block, line, alignment, doc, options) {
988
988
  const splitRuns = splitTextRunsByEastAsia(sliceRunsForLine(block, line));
989
989
  const { runs: runsForLine, collapsedLeadingRuns: collapsedLeadingSpaceRuns, collapsedTrailingRuns: collapsedTrailingSpaceRuns } = splitCollapsibleLineEdgeSpaces(splitRuns, startsAfterSoftWrap(block, line));
990
990
  const isCollapsedLineEdgeSpaceRun = (run) => collapsedLeadingSpaceRuns.has(run) || collapsedTrailingSpaceRuns.has(run);
991
+ const collapsedSpaceMeasureText = collapsedLeadingSpaceRuns.size > 0 || collapsedTrailingSpaceRuns.size > 0 ? createTextMeasurer(doc) : void 0;
991
992
  const renderLineTextRun = (run) => {
992
993
  const runEl = renderTextRun(run, doc);
993
994
  if (collapsedLeadingSpaceRuns.has(run)) runEl.dataset["collapsedLeadingSpaces"] = "true";
994
995
  if (collapsedTrailingSpaceRuns.has(run)) runEl.dataset["collapsedTrailingSpaces"] = "true";
995
996
  if (isCollapsedLineEdgeSpaceRun(run)) {
997
+ const spaceAdvance = (collapsedSpaceMeasureText?.(" ", run.fontSize || 11, run.fontFamily || "Calibri", runMeasureStyle(run)) ?? 0) * ((run.horizontalScale ?? 100) / 100);
998
+ runEl.dataset["collapsedSpaceAdvance"] = String(spaceAdvance);
996
999
  runEl.style.fontSize = "0";
997
1000
  runEl.style.letterSpacing = "0";
998
1001
  runEl.style.wordSpacing = "0";
@@ -1055,7 +1058,7 @@ function renderLine(block, line, alignment, doc, options) {
1055
1058
  lineEl.style.overflow = "visible";
1056
1059
  let tabContext;
1057
1060
  const hasScaledTextRun = runsForLine.some((run) => (isTextRun(run) || isFieldRun(run) || isMathRun(run)) && run.horizontalScale !== void 0 && run.horizontalScale !== 100);
1058
- const measureText = hasTabRuns || hasScaledTextRun ? createTextMeasurer(doc) : void 0;
1061
+ const measureText = collapsedSpaceMeasureText ?? (hasTabRuns || hasScaledTextRun ? createTextMeasurer(doc) : void 0);
1059
1062
  if (hasTabRuns) {
1060
1063
  const explicitStops = options?.tabStops?.map(convertTabStopToCalc);
1061
1064
  const leftIndentTwips = options?.leftIndentPx ? Math.round(options.leftIndentPx * 15) : 0;
@@ -1,4 +1,4 @@
1
- import { getCollapsedLineEdgeCaretGeometry } from "../layout-bridge/dom/clickToPositionDom.js";
1
+ import { findCollapsedLineEdgeCaretTarget } from "../layout-bridge/dom/clickToPositionDom.js";
2
2
  import { findBodyEmptyRuns, findBodyPmSpans } from "../layout-bridge/dom/findBodyPmSpans.js";
3
3
  import { closestHtmlElement } from "../utils/domGuards.js";
4
4
  //#region src/render-dom/RenderedDomContext.ts
@@ -27,7 +27,14 @@ var RenderedDomContextImpl = class {
27
27
  }
28
28
  getCoordinatesForPosition(pmPos) {
29
29
  const containerRect = this.#pagesContainer.getBoundingClientRect();
30
- for (const span of findBodyPmSpans(this.#pagesContainer)) {
30
+ const spans = findBodyPmSpans(this.#pagesContainer);
31
+ const collapsedTarget = findCollapsedLineEdgeCaretTarget(spans, pmPos);
32
+ if (collapsedTarget) return {
33
+ x: (collapsedTarget.geometry.left - containerRect.left) / this.#zoom,
34
+ y: (collapsedTarget.geometry.top - containerRect.top) / this.#zoom,
35
+ height: lineHeightFor(collapsedTarget.span, this.#zoom)
36
+ };
37
+ for (const span of spans) {
31
38
  const pmStart = Number(span.dataset["pmStart"]);
32
39
  const pmEnd = Number(span.dataset["pmEnd"]);
33
40
  if (!(span.classList.contains("layout-run-tab") ? pmPos >= pmStart && pmPos < pmEnd : pmPos >= pmStart && pmPos <= pmEnd)) continue;
@@ -37,12 +44,6 @@ var RenderedDomContextImpl = class {
37
44
  y: (spanRect.top - containerRect.top) / this.#zoom,
38
45
  height: lineHeightFor(span, this.#zoom)
39
46
  };
40
- const collapsedGeometry = getCollapsedLineEdgeCaretGeometry(span);
41
- if (collapsedGeometry) return {
42
- x: (collapsedGeometry.left - containerRect.left) / this.#zoom,
43
- y: (collapsedGeometry.top - containerRect.top) / this.#zoom,
44
- height: lineHeightFor(span, this.#zoom)
45
- };
46
47
  const textNode = textNodeForSpan(span);
47
48
  if (!textNode) return {
48
49
  x: (spanRect.left - containerRect.left) / this.#zoom,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.15.12",
3
+ "version": "0.15.13",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",