@stll/folio-core 0.15.11 → 0.15.12

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.
@@ -61,6 +61,21 @@ type DomCaretPosition = {
61
61
  height: number;
62
62
  pageIndex: number;
63
63
  };
64
+ type CollapsedLineEdgeCaretGeometry = {
65
+ left: number;
66
+ top: number;
67
+ /** Viewport height of the visual anchor. Full-line caret painters retain
68
+ * their own line-height policy instead of using this glyph-box height. */
69
+ height: number;
70
+ };
71
+ /**
72
+ * Resolve viewport geometry for a line-edge space span that the painter keeps
73
+ * addressable but collapses to zero font size. Browser ranges inside those
74
+ * spans report their zero-height baseline as `top`, which makes a caret appear
75
+ * below the line until visible text is typed. Anchor the caret to the nearest
76
+ * painted sibling instead; an all-whitespace line falls back to its line box.
77
+ */
78
+ declare function getCollapsedLineEdgeCaretGeometry(spanEl: HTMLElement): CollapsedLineEdgeCaretGeometry | null;
64
79
  declare function getCaretPositionFromDom(container: HTMLElement, pmPos: number, overlayRect: DOMRect): DomCaretPosition | null;
65
80
  //#endregion
66
- export { DomCaretPosition, DomSelectionRect, clickToPositionDom, findPositionInSpan, getCaretPositionFromDom, getSelectionRectsFromDom };
81
+ export { CollapsedLineEdgeCaretGeometry, DomCaretPosition, DomSelectionRect, clickToPositionDom, findPositionInSpan, getCaretPositionFromDom, getCollapsedLineEdgeCaretGeometry, getSelectionRectsFromDom };
@@ -225,6 +225,29 @@ function getSelectionRectsFromDom(container, from, to, overlayRect) {
225
225
  }
226
226
  return rects;
227
227
  }
228
+ /**
229
+ * Resolve viewport geometry for a line-edge space span that the painter keeps
230
+ * addressable but collapses to zero font size. Browser ranges inside those
231
+ * spans report their zero-height baseline as `top`, which makes a caret appear
232
+ * below the line until visible text is typed. Anchor the caret to the nearest
233
+ * painted sibling instead; an all-whitespace line falls back to its line box.
234
+ */
235
+ function getCollapsedLineEdgeCaretGeometry(spanEl) {
236
+ const isLeading = spanEl.dataset["collapsedLeadingSpaces"] === "true";
237
+ const isTrailing = spanEl.dataset["collapsedTrailingSpaces"] === "true";
238
+ if (!isLeading && !isTrailing) return null;
239
+ let sibling = isTrailing ? spanEl.previousElementSibling : spanEl.nextElementSibling;
240
+ while (sibling && (sibling.getAttribute("data-collapsed-leading-spaces") === "true" || sibling.getAttribute("data-collapsed-trailing-spaces") === "true")) sibling = isTrailing ? sibling.previousElementSibling : sibling.nextElementSibling;
241
+ const spanRect = spanEl.getBoundingClientRect();
242
+ const line = closestHtmlElement(spanEl, ".layout-line");
243
+ const anchorRect = sibling?.getBoundingClientRect() ?? line?.getBoundingClientRect() ?? spanRect;
244
+ const lineRect = line?.getBoundingClientRect();
245
+ return {
246
+ left: spanRect.left,
247
+ top: anchorRect.top,
248
+ height: anchorRect.height || lineRect?.height || 16
249
+ };
250
+ }
228
251
  function getCaretPositionFromDom(container, pmPos, overlayRect) {
229
252
  const spans = htmlQueryAll(container, ".layout-page-content span[data-pm-start][data-pm-end]");
230
253
  for (const spanEl of spans) {
@@ -247,6 +270,17 @@ function getCaretPositionFromDom(container, pmPos, overlayRect) {
247
270
  continue;
248
271
  }
249
272
  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
+ }
250
284
  const textNode = spanEl.firstChild;
251
285
  if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
252
286
  const spanRect = spanEl.getBoundingClientRect();
@@ -301,4 +335,4 @@ function getCaretPositionFromDom(container, pmPos, overlayRect) {
301
335
  return null;
302
336
  }
303
337
  //#endregion
304
- export { clickToPositionDom, findPositionInSpan, getCaretPositionFromDom, getSelectionRectsFromDom };
338
+ export { clickToPositionDom, findPositionInSpan, getCaretPositionFromDom, getCollapsedLineEdgeCaretGeometry, getSelectionRectsFromDom };
@@ -0,0 +1,42 @@
1
+ //#region src/layout-bridge/dom/imeCaretAnchor.d.ts
2
+ type ImeCaretHost = {
3
+ style: {
4
+ transform: string;
5
+ };
6
+ };
7
+ type ImeCaretEditorView = {
8
+ readonly composing: boolean;
9
+ readonly dom?: HTMLElement;
10
+ readonly state: {
11
+ readonly selection: {
12
+ readonly empty: boolean;
13
+ readonly head: number;
14
+ };
15
+ };
16
+ hasFocus: () => boolean;
17
+ coordsAtPos: (position: number) => {
18
+ left: number;
19
+ top: number;
20
+ };
21
+ };
22
+ type VisibleCaretViewportRect = {
23
+ left: number;
24
+ top: number;
25
+ };
26
+ type SyncImeCaretAnchorOptions = {
27
+ hiddenHost: ImeCaretHost | null | undefined;
28
+ editorView: ImeCaretEditorView | null | undefined;
29
+ visibleCaret: VisibleCaretViewportRect | null | undefined;
30
+ };
31
+ /**
32
+ * Align the real off-screen ProseMirror caret with the painted document caret.
33
+ *
34
+ * Native IME candidate windows follow the focused contenteditable selection,
35
+ * not folio's visual caret overlay. Translating the hidden input host preserves
36
+ * split rendering while anchoring CJK candidate UI at the visible insertion
37
+ * point.
38
+ */
39
+ declare const syncImeCaretAnchor: ({ hiddenHost, editorView, visibleCaret }: SyncImeCaretAnchorOptions) => boolean;
40
+ declare const resetImeCaretAnchor: (hiddenHost: ImeCaretHost | null | undefined) => void;
41
+ //#endregion
42
+ export { resetImeCaretAnchor, syncImeCaretAnchor };
@@ -0,0 +1,59 @@
1
+ //#region src/layout-bridge/dom/imeCaretAnchor.ts
2
+ const resetHostTransform = (hiddenHost) => {
3
+ if (hiddenHost) hiddenHost.style.transform = "";
4
+ };
5
+ const getNativeSelectionCaret = (editorView) => {
6
+ const editorDom = editorView.dom;
7
+ const selection = editorDom?.ownerDocument.getSelection();
8
+ if (!editorDom || !selection || selection.rangeCount === 0) return null;
9
+ const range = selection.getRangeAt(0);
10
+ if (!editorDom.contains(range.startContainer)) return null;
11
+ const rect = range.getBoundingClientRect();
12
+ if (range.getClientRects().length === 0 || rect.left === 0 && rect.top === 0 && rect.width === 0 && rect.height === 0) return null;
13
+ return {
14
+ left: rect.left,
15
+ top: rect.top
16
+ };
17
+ };
18
+ /**
19
+ * Align the real off-screen ProseMirror caret with the painted document caret.
20
+ *
21
+ * Native IME candidate windows follow the focused contenteditable selection,
22
+ * not folio's visual caret overlay. Translating the hidden input host preserves
23
+ * split rendering while anchoring CJK candidate UI at the visible insertion
24
+ * point.
25
+ */
26
+ const syncImeCaretAnchor = ({ hiddenHost, editorView, visibleCaret }) => {
27
+ if (!hiddenHost) return false;
28
+ if (!editorView) {
29
+ resetHostTransform(hiddenHost);
30
+ return false;
31
+ }
32
+ if (editorView.composing) return false;
33
+ if (!visibleCaret || !editorView.hasFocus() || !editorView.state.selection.empty) {
34
+ resetHostTransform(hiddenHost);
35
+ return false;
36
+ }
37
+ const previousTransform = hiddenHost.style.transform;
38
+ hiddenHost.style.transform = "";
39
+ let hiddenCaret;
40
+ try {
41
+ hiddenCaret = getNativeSelectionCaret(editorView) ?? editorView.coordsAtPos(editorView.state.selection.head);
42
+ } catch {
43
+ hiddenHost.style.transform = previousTransform;
44
+ return false;
45
+ }
46
+ if (!Number.isFinite(hiddenCaret.left) || !Number.isFinite(hiddenCaret.top) || !Number.isFinite(visibleCaret.left) || !Number.isFinite(visibleCaret.top)) {
47
+ resetHostTransform(hiddenHost);
48
+ return false;
49
+ }
50
+ const dx = Math.round(visibleCaret.left - hiddenCaret.left);
51
+ const dy = Math.round(visibleCaret.top - hiddenCaret.top);
52
+ hiddenHost.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
53
+ return true;
54
+ };
55
+ const resetImeCaretAnchor = (hiddenHost) => {
56
+ resetHostTransform(hiddenHost);
57
+ };
58
+ //#endregion
59
+ export { resetImeCaretAnchor, syncImeCaretAnchor };
@@ -1,3 +1,4 @@
1
+ import { getCollapsedLineEdgeCaretGeometry } from "./dom/clickToPositionDom.js";
1
2
  //#region src/layout-bridge/headerFooterLayout.ts
2
3
  const hfDomCache = {
3
4
  header: null,
@@ -68,6 +69,12 @@ function computeHfCaretRectFromView(view, section, doc = globalThis.document) {
68
69
  const end = Number(span.dataset["pmEnd"]);
69
70
  if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
70
71
  if (pmPos >= start && pmPos <= end) {
72
+ const collapsedGeometry = getCollapsedLineEdgeCaretGeometry(span);
73
+ if (collapsedGeometry) return {
74
+ top: collapsedGeometry.top,
75
+ left: collapsedGeometry.left,
76
+ height: collapsedGeometry.height
77
+ };
71
78
  const range = host.ownerDocument.createRange();
72
79
  const walker = host.ownerDocument.createTreeWalker(span, NodeFilter.SHOW_TEXT);
73
80
  let remaining = pmPos - start;
@@ -1,3 +1,4 @@
1
+ import { getCollapsedLineEdgeCaretGeometry } from "../layout-bridge/dom/clickToPositionDom.js";
1
2
  import { findBodyEmptyRuns, findBodyPmSpans } from "../layout-bridge/dom/findBodyPmSpans.js";
2
3
  import { closestHtmlElement } from "../utils/domGuards.js";
3
4
  //#region src/render-dom/RenderedDomContext.ts
@@ -36,6 +37,12 @@ var RenderedDomContextImpl = class {
36
37
  y: (spanRect.top - containerRect.top) / this.#zoom,
37
38
  height: lineHeightFor(span, this.#zoom)
38
39
  };
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
+ };
39
46
  const textNode = textNodeForSpan(span);
40
47
  if (!textNode) return {
41
48
  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.11",
3
+ "version": "0.15.12",
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",