@broberg/cms-inline-edit 0.5.4 → 0.5.5

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.
package/dist/index.d.cts CHANGED
@@ -52,6 +52,7 @@ interface InlineEditLabels {
52
52
  linkNo?: string;
53
53
  linkEmpty?: string;
54
54
  linkLoading?: string;
55
+ linkCurrent?: string;
55
56
  }
56
57
  interface InlineEditOptions {
57
58
  /** Base URL of the CMS API, e.g. "https://webhouse.app". */
@@ -109,6 +110,51 @@ declare function disconnect(options: InlineEditOptions): void;
109
110
  * MPA consumers (broberg: full reload per navigation re-runs init) never need it.
110
111
  */
111
112
  declare function rescanFields(): void;
113
+ interface LinkablePage {
114
+ collection: string;
115
+ slug: string;
116
+ title: string;
117
+ path: string;
118
+ label: string;
119
+ }
120
+ /**
121
+ * Place a fixed-position popover so it stays fully inside the viewport.
122
+ *
123
+ * The link dialog used to be pinned at `rect.bottom + gap` unconditionally,
124
+ * with `overflow:hidden` and no max-height. On a short laptop screen the
125
+ * dialog's footer — where Gem and Annullér live — sat below the bottom edge
126
+ * with no way to reach it: the dialog could not scroll and the page behind it
127
+ * did not move. The editor could open the dialog and fill it in, but not save.
128
+ * (Reported from production 2026-08-26.)
129
+ *
130
+ * Order matters: the element has to be laid out before its height can be
131
+ * measured, so display is switched on first and the final `top` written after.
132
+ *
133
+ * @param width the popover's own fixed width, used to clamp `left`.
134
+ */
135
+ declare function positionPopover(el: HTMLElement, rect: {
136
+ top: number;
137
+ bottom: number;
138
+ left: number;
139
+ }, gap: number, width: number): void;
140
+ /**
141
+ * Find the page an existing `data-cms-ref` points at.
142
+ *
143
+ * A slug may itself contain a colon, so only the FIRST segment is the
144
+ * collection and everything after it is the slug.
145
+ */
146
+ declare function resolveExistingRef(ref: string | null | undefined, pages: LinkablePage[]): LinkablePage | null;
147
+ /**
148
+ * Render the "currently points at" line for the link being edited.
149
+ *
150
+ * Pure over its arguments so the wording can be pinned by a test. Reading the
151
+ * highlighted row is not enough on its own: it can sit outside the list's 196px
152
+ * window, and a reference to a page that is no longer linkable has no row to
153
+ * highlight at all. Christian, 2026-08-26: "når jeg redigerer et eksisterende
154
+ * link til egen side skal den også vise hvilken vi linker til." A missing page
155
+ * shows its raw reference rather than saying nothing.
156
+ */
157
+ declare function renderCurrentRefLine(box: HTMLElement, ref: string | null | undefined, pages: LinkablePage[]): void;
112
158
  /**
113
159
  * Converts an edited contenteditable region's innerHTML back to Markdown so a
114
160
  * rich article body round-trips through the cms `richtext` contract (fields
@@ -122,4 +168,4 @@ declare function rescanFields(): void;
122
168
  */
123
169
  declare function htmlToMarkdown(html: string): string;
124
170
 
125
- export { type InlineEditLabels, type InlineEditOptions, applyFieldSlice, buildConnectUrl, disconnect, getConnectedToken, htmlToMarkdown, initInlineEdit, rescanFields };
171
+ export { type InlineEditLabels, type InlineEditOptions, applyFieldSlice, buildConnectUrl, disconnect, getConnectedToken, htmlToMarkdown, initInlineEdit, positionPopover, renderCurrentRefLine, rescanFields, resolveExistingRef };
package/dist/index.d.ts CHANGED
@@ -52,6 +52,7 @@ interface InlineEditLabels {
52
52
  linkNo?: string;
53
53
  linkEmpty?: string;
54
54
  linkLoading?: string;
55
+ linkCurrent?: string;
55
56
  }
56
57
  interface InlineEditOptions {
57
58
  /** Base URL of the CMS API, e.g. "https://webhouse.app". */
@@ -109,6 +110,51 @@ declare function disconnect(options: InlineEditOptions): void;
109
110
  * MPA consumers (broberg: full reload per navigation re-runs init) never need it.
110
111
  */
111
112
  declare function rescanFields(): void;
113
+ interface LinkablePage {
114
+ collection: string;
115
+ slug: string;
116
+ title: string;
117
+ path: string;
118
+ label: string;
119
+ }
120
+ /**
121
+ * Place a fixed-position popover so it stays fully inside the viewport.
122
+ *
123
+ * The link dialog used to be pinned at `rect.bottom + gap` unconditionally,
124
+ * with `overflow:hidden` and no max-height. On a short laptop screen the
125
+ * dialog's footer — where Gem and Annullér live — sat below the bottom edge
126
+ * with no way to reach it: the dialog could not scroll and the page behind it
127
+ * did not move. The editor could open the dialog and fill it in, but not save.
128
+ * (Reported from production 2026-08-26.)
129
+ *
130
+ * Order matters: the element has to be laid out before its height can be
131
+ * measured, so display is switched on first and the final `top` written after.
132
+ *
133
+ * @param width the popover's own fixed width, used to clamp `left`.
134
+ */
135
+ declare function positionPopover(el: HTMLElement, rect: {
136
+ top: number;
137
+ bottom: number;
138
+ left: number;
139
+ }, gap: number, width: number): void;
140
+ /**
141
+ * Find the page an existing `data-cms-ref` points at.
142
+ *
143
+ * A slug may itself contain a colon, so only the FIRST segment is the
144
+ * collection and everything after it is the slug.
145
+ */
146
+ declare function resolveExistingRef(ref: string | null | undefined, pages: LinkablePage[]): LinkablePage | null;
147
+ /**
148
+ * Render the "currently points at" line for the link being edited.
149
+ *
150
+ * Pure over its arguments so the wording can be pinned by a test. Reading the
151
+ * highlighted row is not enough on its own: it can sit outside the list's 196px
152
+ * window, and a reference to a page that is no longer linkable has no row to
153
+ * highlight at all. Christian, 2026-08-26: "når jeg redigerer et eksisterende
154
+ * link til egen side skal den også vise hvilken vi linker til." A missing page
155
+ * shows its raw reference rather than saying nothing.
156
+ */
157
+ declare function renderCurrentRefLine(box: HTMLElement, ref: string | null | undefined, pages: LinkablePage[]): void;
112
158
  /**
113
159
  * Converts an edited contenteditable region's innerHTML back to Markdown so a
114
160
  * rich article body round-trips through the cms `richtext` contract (fields
@@ -122,4 +168,4 @@ declare function rescanFields(): void;
122
168
  */
123
169
  declare function htmlToMarkdown(html: string): string;
124
170
 
125
- export { type InlineEditLabels, type InlineEditOptions, applyFieldSlice, buildConnectUrl, disconnect, getConnectedToken, htmlToMarkdown, initInlineEdit, rescanFields };
171
+ export { type InlineEditLabels, type InlineEditOptions, applyFieldSlice, buildConnectUrl, disconnect, getConnectedToken, htmlToMarkdown, initInlineEdit, positionPopover, renderCurrentRefLine, rescanFields, resolveExistingRef };
package/dist/index.js CHANGED
@@ -70,7 +70,8 @@ var DEFAULT_LABELS = {
70
70
  linkYes: "Ja",
71
71
  linkNo: "Nej",
72
72
  linkEmpty: "Ingen sider fundet",
73
- linkLoading: "Henter sider\u2026"
73
+ linkLoading: "Henter sider\u2026",
74
+ linkCurrent: "Linker til"
74
75
  };
75
76
  var uiLabels = DEFAULT_LABELS;
76
77
  function resolveOptions(options) {
@@ -467,6 +468,22 @@ async function fetchLinkablePages() {
467
468
  linkPages = body.pages ?? [];
468
469
  return linkPages;
469
470
  }
471
+ function positionPopover(el, rect, gap, width) {
472
+ const MARGIN = 8;
473
+ const vh = window.innerHeight;
474
+ const vw = window.innerWidth;
475
+ el.style.maxHeight = `${Math.max(120, vh - MARGIN * 2)}px`;
476
+ el.style.overflowY = "auto";
477
+ el.style.display = "block";
478
+ const h = el.getBoundingClientRect().height || el.offsetHeight || 0;
479
+ let top = rect.bottom + gap;
480
+ if (top + h > vh - MARGIN) {
481
+ const above = rect.top - gap - h;
482
+ top = above >= MARGIN ? above : vh - MARGIN - h;
483
+ }
484
+ el.style.top = `${Math.max(MARGIN, top)}px`;
485
+ el.style.left = `${Math.max(MARGIN, Math.min(rect.left, vw - width - MARGIN))}px`;
486
+ }
470
487
  function toggleLinkDialog(anchor) {
471
488
  if (linkDialog && linkDialog.style.display === "block") {
472
489
  hideLinkDialog();
@@ -478,9 +495,12 @@ function toggleLinkDialog(anchor) {
478
495
  if (!linkDialog) linkDialog = buildLinkDialog();
479
496
  renderLinkDialog();
480
497
  const rect = anchor.getBoundingClientRect();
481
- linkDialog.style.top = `${rect.bottom + 8}px`;
482
- linkDialog.style.left = `${Math.min(Math.max(8, rect.left - 180), window.innerWidth - 400)}px`;
483
- linkDialog.style.display = "block";
498
+ positionPopover(
499
+ linkDialog,
500
+ { top: rect.top, bottom: rect.bottom, left: rect.left - 180 },
501
+ 8,
502
+ 392
503
+ );
484
504
  }
485
505
  function buildLinkDialog() {
486
506
  const d = document.createElement("div");
@@ -502,7 +522,7 @@ function renderLinkDialog() {
502
522
  const editing = !!linkEditing;
503
523
  const existingRef = linkEditing?.getAttribute("data-cms-ref") ?? "";
504
524
  const onPageTab = !editing || !!existingRef;
505
- d.innerHTML = `<div style="display:flex;gap:4px;padding:8px 8px 0"><button type="button" data-testid="inline-link-tab-page" data-tab="page" style="${tabCss(onPageTab)}">${L.linkTabPage}</button><button type="button" data-testid="inline-link-tab-url" data-tab="url" style="${tabCss(!onPageTab)}">${L.linkTabUrl}</button></div><div style="padding:10px 12px 12px"><div data-pane="page" style="display:${onPageTab ? "block" : "none"}"><input data-testid="inline-link-search" data-role="search" placeholder="${L.linkSearch}" style="${fieldCss()}"><div data-role="list" data-testid="inline-link-list" style="margin-top:8px;max-height:196px;overflow-y:auto;border:1px solid #3a3f4a;border-radius:8px;background:#141821"></div></div><div data-pane="url" style="display:${onPageTab ? "none" : "block"}"><label style="${labelCss()}">${L.linkUrl}</label><input data-testid="inline-link-url" data-role="url" placeholder="https://\u2026" style="${fieldCss()}"></div><label style="${labelCss()}">${L.linkText}</label><input data-testid="inline-link-text" data-role="text" placeholder="${L.linkTextAuto}" style="${fieldCss()}"><p data-role="hint" style="font-size:11.5px;color:#9aa3b2;margin:6px 0 0;line-height:1.5"></p><div data-role="live" style="display:flex;gap:8px;margin-top:12px;padding:9px 10px;background:rgba(0,178,255,.08);border:1px solid rgba(0,178,255,.28);border-radius:8px"><p style="margin:0;font-size:11.5px;color:#c9d1dd;line-height:1.55">${L.linkLiveHint}</p></div><div style="display:flex;gap:8px;align-items:center;margin-top:14px"><div data-role="remove"></div><div style="flex:1"></div><button type="button" data-testid="inline-link-cancel" data-role="cancel" style="${btnCss(false)}">${L.linkCancel}</button><button type="button" data-testid="inline-link-submit" data-role="submit" style="${btnCss(true)}" disabled>${editing ? L.linkSave : L.linkInsert}</button></div></div>`;
525
+ d.innerHTML = `<div style="display:flex;gap:4px;padding:8px 8px 0"><button type="button" data-testid="inline-link-tab-page" data-tab="page" style="${tabCss(onPageTab)}">${L.linkTabPage}</button><button type="button" data-testid="inline-link-tab-url" data-tab="url" style="${tabCss(!onPageTab)}">${L.linkTabUrl}</button></div><div style="padding:10px 12px 12px"><div data-pane="page" style="display:${onPageTab ? "block" : "none"}"><input data-testid="inline-link-search" data-role="search" placeholder="${L.linkSearch}" style="${fieldCss()}"><div data-role="current" data-testid="inline-link-current" style="display:none;margin-top:8px;padding:7px 10px;background:rgba(0,178,255,.08);border:1px solid rgba(0,178,255,.28);border-radius:8px;font-size:11.5px;color:#c9d1dd;line-height:1.45"></div><div data-role="list" data-testid="inline-link-list" style="margin-top:8px;max-height:196px;overflow-y:auto;border:1px solid #3a3f4a;border-radius:8px;background:#141821"></div></div><div data-pane="url" style="display:${onPageTab ? "none" : "block"}"><label style="${labelCss()}">${L.linkUrl}</label><input data-testid="inline-link-url" data-role="url" placeholder="https://\u2026" style="${fieldCss()}"></div><label style="${labelCss()}">${L.linkText}</label><input data-testid="inline-link-text" data-role="text" placeholder="${L.linkTextAuto}" style="${fieldCss()}"><p data-role="hint" style="font-size:11.5px;color:#9aa3b2;margin:6px 0 0;line-height:1.5"></p><div data-role="live" style="display:flex;gap:8px;margin-top:12px;padding:9px 10px;background:rgba(0,178,255,.08);border:1px solid rgba(0,178,255,.28);border-radius:8px"><p style="margin:0;font-size:11.5px;color:#c9d1dd;line-height:1.55">${L.linkLiveHint}</p></div><div style="display:flex;gap:8px;align-items:center;margin-top:14px"><div data-role="remove"></div><div style="flex:1"></div><button type="button" data-testid="inline-link-cancel" data-role="cancel" style="${btnCss(false)}">${L.linkCancel}</button><button type="button" data-testid="inline-link-submit" data-role="submit" style="${btnCss(true)}" disabled>${editing ? L.linkSave : L.linkInsert}</button></div></div>`;
506
526
  const q = (role) => d.querySelector(`[data-role="${role}"]`);
507
527
  const text = q("text");
508
528
  const urlIn = q("url");
@@ -557,6 +577,28 @@ function buildRemoveLink() {
557
577
  wrap.appendChild(btn);
558
578
  return wrap;
559
579
  }
580
+ function resolveExistingRef(ref, pages) {
581
+ if (!ref) return null;
582
+ const [collection, ...rest] = ref.split(":");
583
+ const slug = rest.join(":");
584
+ if (!collection || !slug) return null;
585
+ return pages.find((p) => p.collection === collection && p.slug === slug) ?? null;
586
+ }
587
+ function renderCurrentRefLine(box, ref, pages) {
588
+ if (!ref) {
589
+ box.style.display = "none";
590
+ box.innerHTML = "";
591
+ return;
592
+ }
593
+ const label = escapeHtml(uiLabels.linkCurrent);
594
+ const page = resolveExistingRef(ref, pages);
595
+ box.style.display = "block";
596
+ box.innerHTML = page ? `${label}: <strong>${escapeHtml(page.title)}</strong> <span style="color:#9aa3b2;font-family:ui-monospace,Menlo,monospace">${escapeHtml(page.path)}</span>` : `${label}: <span style="font-family:ui-monospace,Menlo,monospace">${escapeHtml(ref)}</span>`;
597
+ }
598
+ function paintCurrentRef(pages) {
599
+ const box = linkDialog?.querySelector('[data-role="current"]');
600
+ if (box) renderCurrentRefLine(box, linkEditing?.getAttribute("data-cms-ref"), pages ?? []);
601
+ }
560
602
  function renderLinkList(filter) {
561
603
  const d = linkDialog;
562
604
  if (!d) return;
@@ -582,19 +624,21 @@ function renderLinkList(filter) {
582
624
  syncLinkDialog();
583
625
  };
584
626
  list.appendChild(row);
627
+ if (on) row.scrollIntoView?.({ block: "nearest" });
585
628
  });
586
629
  };
630
+ const preselect = (pages) => {
631
+ if (!linkPicked) linkPicked = resolveExistingRef(linkEditing?.getAttribute("data-cms-ref"), pages);
632
+ paintCurrentRef(pages);
633
+ };
587
634
  if (linkPages) {
635
+ preselect(linkPages);
588
636
  paint(linkPages);
589
637
  return;
590
638
  }
591
639
  list.innerHTML = `<div style="padding:10px;font-size:12.5px;color:#9aa3b2">${uiLabels.linkLoading}</div>`;
592
640
  fetchLinkablePages().then((pages) => {
593
- const ref = linkEditing?.getAttribute("data-cms-ref");
594
- if (ref && !linkPicked) {
595
- const [c, ...rest] = ref.split(":");
596
- linkPicked = pages.find((p) => p.collection === c && p.slug === rest.join(":")) ?? null;
597
- }
641
+ preselect(pages);
598
642
  paint(pages);
599
643
  syncLinkDialog();
600
644
  }).catch(() => {
@@ -669,9 +713,7 @@ function toggleEmojiPicker(anchor) {
669
713
  const sel = window.getSelection();
670
714
  if (sel && sel.rangeCount > 0) savedRange = sel.getRangeAt(0).cloneRange();
671
715
  const rect = anchor.getBoundingClientRect();
672
- emojiPicker.style.top = `${rect.bottom + 6}px`;
673
- emojiPicker.style.left = `${Math.min(rect.left, window.innerWidth - 240)}px`;
674
- emojiPicker.style.display = "block";
716
+ positionPopover(emojiPicker, rect, 6, 230);
675
717
  }
676
718
  function hideEmojiPicker() {
677
719
  if (emojiPicker) emojiPicker.style.display = "none";
@@ -804,7 +846,7 @@ function serializeBlockChildren(parent) {
804
846
  const s = serializeBlock(node).trim();
805
847
  if (s) blocks.push(s);
806
848
  } else {
807
- inlineRun += serializeInlineNode(node);
849
+ inlineRun = joinInline(inlineRun, serializeInlineNode(node));
808
850
  }
809
851
  });
810
852
  flushInline();
@@ -895,10 +937,30 @@ function preservedLinkAttrs(el) {
895
937
  }
896
938
  return out;
897
939
  }
940
+ var HARD_BREAK = " \n";
941
+ function edgeWhitespace(ws) {
942
+ if (!ws) return "";
943
+ return /\n/.test(ws) ? HARD_BREAK : " ";
944
+ }
945
+ function wrapEmphasis(inner, delimiter) {
946
+ const core = inner.trim();
947
+ if (!core) return "";
948
+ const lead = edgeWhitespace(inner.slice(0, inner.length - inner.trimStart().length));
949
+ const trail = edgeWhitespace(inner.slice(inner.trimEnd().length));
950
+ return lead + delimiter + core + delimiter + trail;
951
+ }
952
+ function joinInline(out, piece) {
953
+ if (!piece) return out;
954
+ const outTail = /\s*$/.exec(out)[0];
955
+ const pieceHead = /^\s*/.exec(piece)[0];
956
+ if (!outTail || !pieceHead) return out + piece;
957
+ const merged = /\n/.test(outTail + pieceHead) ? HARD_BREAK : " ";
958
+ return out.slice(0, out.length - outTail.length) + merged + piece.slice(pieceHead.length);
959
+ }
898
960
  function serializeInline(node) {
899
961
  let out = "";
900
962
  node.childNodes.forEach((child) => {
901
- out += serializeInlineNode(child);
963
+ out = joinInline(out, serializeInlineNode(child));
902
964
  });
903
965
  return out;
904
966
  }
@@ -915,11 +977,11 @@ function serializeInlineNode(child) {
915
977
  switch (tag) {
916
978
  case "strong":
917
979
  case "b":
918
- out += inner.trim() ? `**${inner.trim()}**` : "";
980
+ out += wrapEmphasis(inner, "**");
919
981
  break;
920
982
  case "em":
921
983
  case "i":
922
- out += inner.trim() ? `*${inner.trim()}*` : "";
984
+ out += wrapEmphasis(inner, "*");
923
985
  break;
924
986
  case "code":
925
987
  out += "`" + inner + "`";
@@ -1065,6 +1127,9 @@ export {
1065
1127
  getConnectedToken,
1066
1128
  htmlToMarkdown,
1067
1129
  initInlineEdit,
1068
- rescanFields
1130
+ positionPopover,
1131
+ renderCurrentRefLine,
1132
+ rescanFields,
1133
+ resolveExistingRef
1069
1134
  };
1070
1135
  //# sourceMappingURL=index.js.map