@kubex/zinc 1.1.118 → 1.1.120

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.
@@ -362,6 +362,8 @@ This ensures forms remain usable on all devices without additional configuration
362
362
 
363
363
  The label column sticks to the top of the scroll container, so on a long form it stays beside the inputs instead of scrolling away. Use the `--zn-form-group-sticky-top` custom property to change the offset it settles at — useful when the scroll container has a sticky header of its own.
364
364
 
365
+ Where a container clips its overflow but never scrolls — a `zn-panel` body sized to its content, say — native sticky anchors to it and the label would sit still for the whole scroll. The group follows the container that is actually scrolled instead, off a scroll timeline where the browser has them so the compositor keeps the label with the fields, and off a scroll handler where it does not.
366
+
365
367
  ```html:preview
366
368
  <div style="max-height: 300px; overflow-y: auto;">
367
369
  <zn-form-group label="Delivery Details" help-text="This label follows the inputs as you scroll">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.118",
3
+ "version": "1.1.120",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -45,8 +45,10 @@ export default class ZnFormGroup extends ZincElement {
45
45
 
46
46
  @property({ attribute: 'pad', type: Boolean }) pad: boolean = false;
47
47
 
48
- /** The scroller the label is tracked against by hand; null while native sticky is enough. */
48
+ /** The scroller the label is moved against by hand; null while native sticky is enough. */
49
49
  private tracked: HTMLElement | null = null;
50
+ /** Set while the compositor is running the movement off a scroll timeline instead. */
51
+ private animation: Animation | null = null;
50
52
  private stickyTop: number = 0;
51
53
  private frame: number = 0;
52
54
  private rebind: boolean = false;
@@ -66,7 +68,7 @@ export default class ZnFormGroup extends ZincElement {
66
68
  super.disconnectedCallback();
67
69
  this.resizeObserver?.disconnect();
68
70
  window.removeEventListener('resize', this.onViewportResize);
69
- this.trackScroller(null);
71
+ this.detach();
70
72
  cancelAnimationFrame(this.frame);
71
73
  this.frame = 0;
72
74
  }
@@ -89,7 +91,7 @@ export default class ZnFormGroup extends ZincElement {
89
91
  this.frame = 0;
90
92
  if (this.rebind) {
91
93
  this.rebind = false;
92
- this.findScroller();
94
+ this.bind();
93
95
  }
94
96
  this.positionLabel();
95
97
  });
@@ -98,32 +100,94 @@ export default class ZnFormGroup extends ZincElement {
98
100
  /**
99
101
  * Native sticky only follows the nearest scroll container. Where that container isn't the one
100
102
  * the user actually scrolls — a `zn-panel` body sized to its content inside a scrolling
101
- * slideout, say — the label never moves, so it gets translated by hand instead.
103
+ * slideout, say — the label never moves, so it is moved against the real scroller instead.
102
104
  */
103
- private findScroller() {
105
+ private bind() {
104
106
  const column = this.labelColumn;
105
107
  if (!column) return;
106
108
 
109
+ // Measured with nothing of ours on the label, so what follows reads its resting position.
110
+ this.detach();
107
111
  column.style.top = '';
108
112
  this.stickyTop = parseFloat(getComputedStyle(column).top) || 0;
109
113
 
110
114
  const anchor = this.nearestScrollContainer(column);
111
- const scroller = anchor ? this.scrollingAncestor(column) : null;
112
- const anchored = !anchor || scroller === anchor;
115
+ if (!anchor) return;
113
116
 
114
- this.trackScroller(anchored ? null : scroller);
117
+ const scroller = this.scrollingAncestor(column);
118
+ if (scroller === anchor) return;
115
119
 
116
120
  // A `top` inset against a box that never scrolls has nothing to hold the label back from:
117
- // it only pushes the label down the page, so drop it and let the transform do the work.
118
- if (!anchored) column.style.top = '0px';
121
+ // it only pushes the label down the page, so drop it and stand in for sticky below.
122
+ column.style.top = '0px';
123
+ if (!scroller) return;
124
+
125
+ this.tracked = scroller;
126
+
127
+ /*
128
+ * Scrolling is composited, so a transform written from a scroll handler lands a frame late and the label swims
129
+ * against the fields. A scroll timeline hands the same movement to the compositor; the handler is the fallback
130
+ * for browsers without one.
131
+ */
132
+ this.animation = this.scrollLinkedTravel(scroller);
133
+ if (this.animation) {
134
+ this.animation.play();
135
+ // Content arriving above the form moves the scroll position the label has to start from, and nothing resizes
136
+ // to say so. Re-measured at the end of a gesture, which is the soonest it can be seen.
137
+ this.scrollTarget(scroller)?.addEventListener('scrollend', this.onScrollEnd, { passive: true });
138
+ return;
139
+ }
140
+
141
+ this.scrollTarget(scroller)?.addEventListener('scroll', this.onScroll, { passive: true });
119
142
  }
120
143
 
121
- private trackScroller(scroller: HTMLElement | null) {
122
- if (scroller === this.tracked) return;
144
+ /**
145
+ * The label's whole journey, as the scroll positions it turns at: still until the fieldset's top reaches the sticky
146
+ * line, then a pixel for every pixel of scroll until it has crossed the fieldset. `fill: both` holds it at either
147
+ * end, which is the clamp the scroll handler applies by hand.
148
+ */
149
+ private scrollLinkedTravel(scroller: HTMLElement): Animation | null {
150
+ const column = this.labelColumn;
151
+ const fieldset = this.fieldset;
152
+ if (!column || !fieldset || typeof ScrollTimeline === 'undefined') return null;
153
+
154
+ // The stylesheet drops sticky while the columns are stacked, and there is nothing to follow.
155
+ if (getComputedStyle(column).position !== 'sticky') return null;
156
+
157
+ const travel = Math.max(0, fieldset.clientHeight - column.offsetHeight);
158
+ const range = scroller.scrollHeight - scroller.clientHeight;
159
+ if (travel < 1 || range < 1) return null;
160
+
161
+ const start = scroller.scrollTop + column.getBoundingClientRect().top
162
+ - this.visibleTop(scroller) - this.stickyTop;
163
+ const held = (scroll: number) => Math.min(Math.max(scroll - start, 0), travel);
164
+
165
+ const knees = [start, start + travel].filter(scroll => scroll > 0 && scroll < range);
166
+ const keyframes = [0, ...knees, range].map(scroll => ({
167
+ offset: Math.min(Math.max(scroll / range, 0), 1),
168
+ transform: `translateY(${held(scroll)}px)`
169
+ }));
170
+
171
+ return new Animation(
172
+ new KeyframeEffect(column, keyframes, { fill: 'both' }),
173
+ new ScrollTimeline({ source: scroller, axis: 'block' })
174
+ );
175
+ }
123
176
 
124
- this.scrollTarget(this.tracked)?.removeEventListener('scroll', this.onScroll);
125
- this.tracked = scroller;
126
- this.scrollTarget(this.tracked)?.addEventListener('scroll', this.onScroll, { passive: true });
177
+ /** Drops everything this component has put on the label or on the scroller. */
178
+ private detach() {
179
+ const target = this.scrollTarget(this.tracked);
180
+ target?.removeEventListener('scroll', this.onScroll);
181
+ target?.removeEventListener('scrollend', this.onScrollEnd);
182
+ this.tracked = null;
183
+
184
+ this.animation?.cancel();
185
+ this.animation = null;
186
+
187
+ const column = this.labelColumn;
188
+ if (!column) return;
189
+ this.offset = 0;
190
+ column.style.transform = '';
127
191
  }
128
192
 
129
193
  /** The document scrolls through the window, every other scroller reports its own events. */
@@ -132,23 +196,34 @@ export default class ZnFormGroup extends ZincElement {
132
196
  return scroller === document.scrollingElement ? window : scroller;
133
197
  }
134
198
 
199
+ /** Where the scroller's own top edge sits, which for the document is the top of the viewport. */
200
+ private visibleTop(scroller: HTMLElement) {
201
+ return scroller === document.scrollingElement ? 0 : scroller.getBoundingClientRect().top;
202
+ }
203
+
204
+ private get fieldset(): HTMLElement | null {
205
+ return this.shadowRoot?.querySelector('.form-control') ?? null;
206
+ }
207
+
135
208
  private readonly onScroll = () => this.schedule();
136
209
 
210
+ private readonly onScrollEnd = () => this.schedule(true);
211
+
137
212
  // A shorter viewport can make an ancestor scrollable without changing this form's size.
138
213
  private readonly onViewportResize = () => this.schedule(true);
139
214
 
140
215
  private positionLabel() {
216
+ if (this.animation) return;
217
+
141
218
  const column = this.labelColumn;
142
- const fieldset = this.shadowRoot?.querySelector<HTMLElement>('.form-control');
219
+ const fieldset = this.fieldset;
143
220
  if (!column || !fieldset) return;
144
221
 
145
222
  let offset = 0;
146
223
 
147
224
  // The stylesheet drops sticky while the columns are stacked; tracking has to stand down too.
148
225
  if (this.tracked && getComputedStyle(column).position === 'sticky') {
149
- const visibleTop = this.tracked === document.scrollingElement
150
- ? 0
151
- : this.tracked.getBoundingClientRect().top;
226
+ const visibleTop = this.visibleTop(this.tracked);
152
227
  const restingTop = column.getBoundingClientRect().top - this.offset;
153
228
  const travel = Math.max(0, fieldset.clientHeight - column.offsetHeight);
154
229
 
@@ -93,6 +93,26 @@ describe('<zn-form-group>', () => {
93
93
  expect(label).to.be.closeTo(inputs, 2);
94
94
  });
95
95
 
96
+ it('hands the movement to a scroll timeline where the browser has one', async () => {
97
+ if (!window.ScrollTimeline) return;
98
+
99
+ const el = await fixture<HTMLElement>(html`
100
+ <div style="max-height: 300px; overflow-y: auto">
101
+ <zn-panel>
102
+ <zn-form-group label="Sticky"></zn-form-group>
103
+ </zn-panel>
104
+ </div>`);
105
+ const group = el.querySelector<HTMLElement>('zn-form-group')!;
106
+ group.innerHTML = tallForm;
107
+ await new Promise(resolve => setTimeout(resolve, 400));
108
+
109
+ const label = group.shadowRoot!.querySelector<HTMLElement>('.form-control__text')!;
110
+ const [animation] = label.getAnimations();
111
+
112
+ expect(animation, 'the compositor drives the label, not the scroll handler').to.exist;
113
+ expect(animation.timeline).to.be.instanceOf(window.ScrollTimeline);
114
+ });
115
+
96
116
  it('holds the label in view when a panel sits between the form and the scroll container', async () => {
97
117
  const el = await fixture<HTMLElement>(html`
98
118
  <div style="max-height: 300px; overflow-y: auto">
@@ -25,7 +25,7 @@ export interface EditorAction {
25
25
  /** Where the caret lands within `prefix`. Defaults to the end. */
26
26
  caretOffset?: number;
27
27
  /** Actions that open their own picker instead of inserting text. */
28
- opens?: 'image' | 'include';
28
+ opens?: 'image' | 'include' | 'link';
29
29
  }
30
30
 
31
31
  /** Toolbar order, most-used first — the last groups are the first to collapse. */
@@ -128,6 +128,7 @@ export const EDITOR_ACTIONS: EditorAction[] = [
128
128
  {key: 'tooltip', label: 'Tooltip', icon: 'message-circle-question-mark@lu', group: 'inline', keywords: ['term'], inline: {before: '{', after: '}(Explanation)', placeholder: 'Term'}},
129
129
  {key: 'cross-reference', label: 'Cross reference', icon: 'link-2@lu', group: 'inline', keywords: ['xref'], inline: {before: '<<', after: '>>', placeholder: 'section,Label'}},
130
130
  {key: 'links-and-images', label: 'Link', icon: 'link@lu', group: 'inline', inline: {before: '[', after: '](https://)', placeholder: 'Label'}},
131
+ {key: 'document-link', label: 'Link to article', icon: 'file-symlink@lu', group: 'inline', keywords: ['article', 'document', 'kb'], opens: 'link'},
131
132
  {key: 'passthrough', label: 'Passthrough', icon: 'shield@lu', group: 'inline', keywords: ['raw', 'literal'], inline: {before: 'pass:[', after: ']', placeholder: 'raw'}},
132
133
  {key: 'curly-bang-passthrough', label: 'Literal braces', icon: 'braces@lu', group: 'inline', inline: {before: '{!', after: '!}', placeholder: 'raw'}},
133
134
  ];
@@ -49,9 +49,22 @@ interface IncludeOption {
49
49
  url?: string;
50
50
  }
51
51
 
52
+ interface LinkOption {
53
+ ref: string;
54
+ kind: string;
55
+ title: string;
56
+ context?: string;
57
+ status?: string;
58
+ }
59
+
52
60
  /** remarkd's include directive on a line of its own: `include::<target>[<label>]`. */
53
61
  const INCLUDE_LINE = /^include::([^[]+)\[(.*)]\s*$/;
54
62
 
63
+ /** The reference a content link carries: `kb:<kind>/<id>`. */
64
+ const CONTENT_LINK_PATTERN = 'kb:(?:document|category|page)\\/[A-Za-z0-9_-]+';
65
+ const CONTENT_LINK_HREF = new RegExp(`^${CONTENT_LINK_PATTERN}$`);
66
+ const CONTENT_LINK_REFS = new RegExp(CONTENT_LINK_PATTERN, 'g');
67
+
55
68
  /** A document attribute definition: `:name: value`, `:flag:`, or `:flag!:` to unset. */
56
69
  const ATTRIBUTE_LINE = /^:([A-Za-z0-9_-]+)(!)?:\s*(.*)$/;
57
70
 
@@ -119,12 +132,28 @@ const CONDITIONAL_INLINE = /^(ifdef|ifndef|iftrue|iffalse|ifempty|ifnempty)::([^
119
132
  * directive, so they are replaced rather than escaped.
120
133
  */
121
134
  function includeMarker(id: string, title: string): string {
135
+ // Collapses a run of newlines to one space, unlike contentLinkMarkup below —
136
+ // that one replaces char-for-char to stay byte-for-byte with app-kb's
137
+ // model.ContentLinkMarkup; don't merge the two formatters.
122
138
  const label = title.replace(/[\r\n]+/g, ' ').replace(/\[/g, '(').replace(/]/g, ')').trim();
123
139
  return `include::${id}[${label}]`;
124
140
  }
125
141
 
142
+ /**
143
+ * The markup a body carries for a link to other knowledge-base content.
144
+ * Mirrors app-kb's `model.ContentLinkMarkup`: brackets and newlines in the
145
+ * label would end the link early, so they are replaced rather than escaped.
146
+ */
147
+ function contentLinkMarkup(ref: string, label: string): string {
148
+ const text = label.replace(/[\r\n]/g, ' ').replace(/\[/g, '(').replace(/]/g, ')').trim();
149
+ return `[${text}](${ref})`;
150
+ }
151
+
126
152
  const SLASH_ITEMS = slashItems(EDITOR_ACTIONS);
127
153
 
154
+ /** Keystrokes settle before the link endpoint is asked again. */
155
+ const LINK_SEARCH_DEBOUNCE = 200;
156
+
128
157
  /**
129
158
  * For an asymmetric mark whose closer embeds a variable payload in parentheses — Link's
130
159
  * `](https://)`, Tooltip's `}(Explanation)` — matches the closer by its fixed prefix rather
@@ -331,9 +360,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
331
360
  menu: () => this.mountSlashMenu(),
332
361
  // The menu only belongs in a block that is nothing but the slash command — a
333
362
  // block prefix like "## " is not valid remarkd part-way through a line.
334
- items: () => this.isSlashBlock()
335
- ? SLASH_ITEMS.filter(item => item.action !== 'include' || !!this.includeUrl)
336
- : [],
363
+ items: () => this.isSlashBlock() ? SLASH_ITEMS.filter(item => this.slashItemAvailable(item)) : [],
337
364
  onSelect: item => this.handleSlashSelect(item)
338
365
  });
339
366
 
@@ -371,6 +398,17 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
371
398
  @state() private includeLoadFailed = false;
372
399
  @state() private includePickerIndex: number | null = null;
373
400
  @state() private includeQuery = '';
401
+ @state() private linkPickerOpen = false;
402
+ @state() private linkQuery = '';
403
+ @state() private linkResults: LinkOption[] | null = null;
404
+ @state() private linkSearchFailed = false;
405
+ private linkSelection: [number, number] | null = null;
406
+ private linkSearchTimer?: ReturnType<typeof setTimeout>;
407
+ private linkSearchToken = 0;
408
+
409
+ /** Resolved link targets by reference; a null value is one the app does not know. */
410
+ private linkRefs = new Map<string, LinkOption | null>();
411
+ private linkRefsPending = false;
374
412
 
375
413
  private pendingDragHandle: HTMLElement | null = null;
376
414
  private dragStartX = 0;
@@ -405,6 +443,14 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
405
443
  */
406
444
  @property({attribute: 'include-url'}) includeUrl = '';
407
445
 
446
+ /**
447
+ * Endpoint the article link picker searches, as
448
+ * `{"items":[{ref,kind,title,context,status}]}`. Queried with `?q=<term>` as
449
+ * the author types and with `?refs=a,b` to resolve the references a body
450
+ * already carries.
451
+ */
452
+ @property({attribute: 'link-url'}) linkUrl = '';
453
+
408
454
  /** Adds a toolbar toggle that swaps the block view for the full remarkd source. */
409
455
  @property({type: Boolean, attribute: 'allow-raw', reflect: true}) allowRaw = false;
410
456
 
@@ -465,6 +511,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
465
511
  super.firstUpdated(_changedProperties);
466
512
  this.formControlController.updateValidity();
467
513
  if (this.hasIncludeBlock()) void this.loadIncludeOptions();
514
+ this.resolveContentLinks();
468
515
  }
469
516
 
470
517
  protected updated(changedProperties: PropertyValues<this>) {
@@ -472,6 +519,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
472
519
  // Scoped to the parser-rendered branch only — see the class comment on the rendered div.
473
520
  this.shadowRoot?.querySelectorAll('.remarkd-editor__rendered--parsed')
474
521
  .forEach(rendered => this.markVariables(rendered));
522
+ this.shadowRoot?.querySelectorAll('.remarkd-editor__rendered--parsed')
523
+ .forEach(rendered => this.markContentLinks(rendered));
475
524
  }
476
525
 
477
526
  disconnectedCallback() {
@@ -487,6 +536,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
487
536
  }
488
537
  this.blocks = this.splitBlocks(this.value || '');
489
538
  if (this.hasUpdated && this.hasIncludeBlock()) void this.loadIncludeOptions();
539
+ if (this.hasUpdated) this.resolveContentLinks();
490
540
  }
491
541
 
492
542
  @watch('includeUrl', {waitUntilFirstUpdate: true})
@@ -497,6 +547,13 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
497
547
  if (this.hasIncludeBlock()) void this.loadIncludeOptions();
498
548
  }
499
549
 
550
+ @watch('linkUrl', {waitUntilFirstUpdate: true})
551
+ handleLinkUrlChange() {
552
+ this.linkRefs.clear();
553
+ this.linkRefsPending = false;
554
+ this.resolveContentLinks();
555
+ }
556
+
500
557
  /**
501
558
  * Splits remarkd source into blocks on blank lines, keeping fenced /
502
559
  * delimited containers (``` ==== !!!! .... ---- ____ **** ////) as single blocks.
@@ -682,6 +739,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
682
739
  this.editingDraft = draft ?? this.blocks[index] ?? '';
683
740
  this.editingIndex = index;
684
741
  this.imageEdit = null;
742
+ this.linkPickerOpen = false;
743
+ this.linkSelection = null;
685
744
  this.editShell = this.computeEditShell(this.editingDraft);
686
745
  this.slashController.close();
687
746
  void this.focusInput(align, caretOffset);
@@ -923,6 +982,12 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
923
982
 
924
983
  /** Returns false for items the controller should not insert text for. */
925
984
  private handleSlashSelect(item: SlashMenuItem): boolean {
985
+ if (item.action === 'link') {
986
+ // The controller strips the "/…" text first, leaving the caret where the
987
+ // command was — which is where the link belongs.
988
+ void this.updateComplete.then(() => this.pickLink());
989
+ return false;
990
+ }
926
991
  if (item.action !== 'image' && item.action !== 'include') return true;
927
992
 
928
993
  const index = this.editingIndex ?? this.blocks.length;
@@ -1122,6 +1187,66 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1122
1187
  void this.revealAfterUpdate('.remarkd-editor__include-picker', 'center');
1123
1188
  }
1124
1189
 
1190
+ /**
1191
+ * Opens the picker over the block being edited. The caret range is captured
1192
+ * now: the picker's own filter takes focus, so the textarea's selection is
1193
+ * gone by the time an option is chosen.
1194
+ */
1195
+ private pickLink() {
1196
+ if (this.disabled || this.readonly || !this.linkUrl || this.editingIndex === null) return;
1197
+ const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
1198
+ if (!input) return;
1199
+ this.linkSelection = [input.selectionStart, input.selectionEnd];
1200
+ this.linkQuery = input.value.slice(input.selectionStart, input.selectionEnd).trim();
1201
+ this.linkResults = null;
1202
+ this.linkSearchFailed = false;
1203
+ this.linkPickerOpen = true;
1204
+ // The picker renders inside the editing block and its filter takes focus,
1205
+ // so the textarea's blur must not commit the edit — that would unmount the
1206
+ // picker mid-interaction.
1207
+ this.suppressBlurCommit = true;
1208
+ this.searchLinks(this.linkQuery);
1209
+ void this.updateComplete.then(() => {
1210
+ this.shadowRoot?.querySelector<HTMLInputElement>('.remarkd-editor__link-filter')?.focus();
1211
+ });
1212
+ }
1213
+
1214
+ private closeLinkPicker = () => {
1215
+ this.linkPickerOpen = false;
1216
+ this.linkSelection = null;
1217
+ this.suppressBlurCommit = false;
1218
+ clearTimeout(this.linkSearchTimer);
1219
+ void this.updateComplete.then(() => {
1220
+ this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')
1221
+ ?.focus({preventScroll: true});
1222
+ });
1223
+ };
1224
+
1225
+ /** Debounced; only the newest response is kept. */
1226
+ private searchLinks(term: string) {
1227
+ clearTimeout(this.linkSearchTimer);
1228
+ const token = ++this.linkSearchToken;
1229
+ this.linkSearchTimer = setTimeout(() => {
1230
+ const url = `${this.linkUrl}${this.linkUrl.includes('?') ? '&' : '?'}q=${encodeURIComponent(term)}`;
1231
+ fetch(url, {headers: {Accept: 'application/json'}})
1232
+ .then(res => {
1233
+ if (!res.ok) throw new Error(`link search failed: ${res.status}`);
1234
+ return res.json() as Promise<{items?: LinkOption[]}>;
1235
+ })
1236
+ .then(data => {
1237
+ if (token !== this.linkSearchToken) return;
1238
+ this.linkResults = data.items ?? [];
1239
+ this.linkSearchFailed = false;
1240
+ })
1241
+ .catch(error => {
1242
+ console.error('[zn-remarkd-editor] link search failed', error);
1243
+ if (token !== this.linkSearchToken) return;
1244
+ this.linkResults = null;
1245
+ this.linkSearchFailed = true;
1246
+ });
1247
+ }, LINK_SEARCH_DEBOUNCE);
1248
+ }
1249
+
1125
1250
  private closeIncludePicker = () => {
1126
1251
  this.includePickerIndex = null;
1127
1252
  };
@@ -1225,6 +1350,50 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1225
1350
  return this.includeRequest;
1226
1351
  }
1227
1352
 
1353
+ /**
1354
+ * Resolves the references in the body that have not been resolved yet, so a
1355
+ * link whose target is gone can be marked. A failed request records nothing:
1356
+ * an unanswered reference is not a broken one.
1357
+ */
1358
+ private resolveContentLinks() {
1359
+ if (!this.linkUrl || this.linkRefsPending) return;
1360
+ const refs = [...new Set((this.value || '').match(CONTENT_LINK_REFS) ?? [])]
1361
+ .filter(ref => !this.linkRefs.has(ref));
1362
+ if (!refs.length) return;
1363
+
1364
+ this.linkRefsPending = true;
1365
+ const url = `${this.linkUrl}${this.linkUrl.includes('?') ? '&' : '?'}refs=${encodeURIComponent(refs.join(','))}`;
1366
+ fetch(url, {headers: {Accept: 'application/json'}})
1367
+ .then(res => {
1368
+ if (!res.ok) throw new Error(`link resolve failed: ${res.status}`);
1369
+ return res.json() as Promise<{items?: LinkOption[]}>;
1370
+ })
1371
+ .then(data => {
1372
+ const known = new Map((data.items ?? []).map(item => [item.ref, item]));
1373
+ for (const ref of refs) this.linkRefs.set(ref, known.get(ref) ?? null);
1374
+ this.requestUpdate();
1375
+ })
1376
+ .catch(error => {
1377
+ console.error('[zn-remarkd-editor] link resolve failed', error);
1378
+ })
1379
+ .finally(() => {
1380
+ // Don't retry a failed request here: these refs are still absent from
1381
+ // linkRefs, so the next value change calls this again — retrying in
1382
+ // finally instead would loop forever on a persistent failure.
1383
+ this.linkRefsPending = false;
1384
+ });
1385
+ }
1386
+
1387
+ private markContentLinks(root: Element) {
1388
+ root.querySelectorAll<HTMLAnchorElement>('a[href^="kb:"]').forEach(anchor => {
1389
+ const match = CONTENT_LINK_HREF.exec(anchor.getAttribute('href') ?? '');
1390
+ const missing = !!match && this.linkRefs.get(match[0]) === null;
1391
+ anchor.classList.toggle('remarkd-editor__link--missing', missing);
1392
+ if (missing) anchor.title = 'This article is no longer available';
1393
+ else anchor.removeAttribute('title');
1394
+ });
1395
+ }
1396
+
1228
1397
  private autosize(input: HTMLTextAreaElement) {
1229
1398
  input.style.height = 'auto';
1230
1399
  input.style.height = `${input.scrollHeight}px`;
@@ -1351,7 +1520,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1351
1520
  this.pickImage(index);
1352
1521
  } else if (action.opens === 'include') {
1353
1522
  this.pickInclude(index);
1354
- } else {
1523
+ } else if (!action.opens) {
1355
1524
  this.insertDraftBlock(index, action.prefix ?? '', action.caretOffset);
1356
1525
  }
1357
1526
  }
@@ -1552,6 +1721,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1552
1721
  @paste=${this.handleEditPaste}
1553
1722
  @blur=${this.handleEditBlur}></textarea>
1554
1723
  </div>
1724
+ ${this.linkPickerOpen ? this.renderLinkPicker() : ''}
1555
1725
  </div>`;
1556
1726
  }
1557
1727
 
@@ -1601,16 +1771,31 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1601
1771
  </div>`;
1602
1772
  }
1603
1773
 
1604
- /** An inline action needs an open block to apply its mark to. */
1774
+ /** Inline marks and the article link both apply into an open block, not a new one. */
1605
1775
  private isActionDisabled(action: EditorAction): boolean {
1606
- return !!action.inline && this.editingIndex === null;
1776
+ return (!!action.inline || action.opens === 'link') && this.editingIndex === null;
1777
+ }
1778
+
1779
+ /** A picker action with no endpoint configured is not offered at all. */
1780
+ private actionAvailable(action: EditorAction): boolean {
1781
+ if (action.opens === 'include') return !!this.includeUrl;
1782
+ if (action.opens === 'link') return !!this.linkUrl;
1783
+ return true;
1784
+ }
1785
+
1786
+ private slashItemAvailable(item: SlashMenuItem): boolean {
1787
+ if (item.action === 'include') return !!this.includeUrl;
1788
+ if (item.action === 'link') return !!this.linkUrl;
1789
+ return true;
1607
1790
  }
1608
1791
 
1609
1792
  /** Routes a toolbar/menu action to the inline or block insert path — the one place both
1610
1793
  * `renderAction` and `renderMenuAction` call, so the bar and the overflow menu cannot
1611
1794
  * drift out of sync on what a given action actually does. */
1612
1795
  private activateAction(action: EditorAction) {
1613
- if (action.inline) {
1796
+ if (action.opens === 'link') {
1797
+ this.pickLink();
1798
+ } else if (action.inline) {
1614
1799
  this.applyInline(action.inline);
1615
1800
  } else {
1616
1801
  this.handleToolbarInsert(action);
@@ -1656,7 +1841,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1656
1841
  .map(group => ({
1657
1842
  group,
1658
1843
  actions: EDITOR_ACTIONS.filter(action => action.group === group.id
1659
- && (action.opens !== 'include' || !!this.includeUrl)),
1844
+ && this.actionAvailable(action)),
1660
1845
  }))
1661
1846
  .filter(entry => entry.actions.length);
1662
1847
  const visible = this.toolbarOverflow.visibleCount;
@@ -1675,7 +1860,11 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1675
1860
  }}
1676
1861
  @zn-hide=${(e: Event) => {
1677
1862
  if (e.target !== e.currentTarget) return;
1678
- this.suppressBlurCommit = false;
1863
+ // Choosing "Insert link" from this menu opens the link picker,
1864
+ // which sets suppressBlurCommit itself; clearing it here
1865
+ // unconditionally would race the picker's own flag and blur
1866
+ // the textarea out from under it.
1867
+ if (!this.linkPickerOpen) this.suppressBlurCommit = false;
1679
1868
  }}>
1680
1869
  <zn-button slot="trigger" type="button" icon-button plain icon="ellipsis@lu"
1681
1870
  icon-size="18" tooltip="More"
@@ -1771,6 +1960,59 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1771
1960
  </div>`;
1772
1961
  }
1773
1962
 
1963
+ private renderLinkPicker() {
1964
+ const kinds: Record<string, string> = {document: 'Article', category: 'Category', page: 'Page'};
1965
+ return html`
1966
+ <div part="link-picker" class="remarkd-editor__link-picker">
1967
+ <div class="remarkd-editor__link-picker-head">
1968
+ <input class="remarkd-editor__link-filter"
1969
+ placeholder="Find an article"
1970
+ .value=${this.linkQuery}
1971
+ @input=${(e: Event) => {
1972
+ this.linkQuery = (e.target as HTMLInputElement).value;
1973
+ this.searchLinks(this.linkQuery);
1974
+ }}>
1975
+ <zn-button type="button" icon-button="small" plain icon="x@lu"
1976
+ tooltip="Cancel" @click=${this.closeLinkPicker}></zn-button>
1977
+ </div>
1978
+ ${this.linkResults === null
1979
+ ? html`<div class="remarkd-editor__link-picker-empty">${
1980
+ this.linkSearchFailed ? 'Could not search for articles' : 'Searching…'}</div>`
1981
+ : this.linkResults.length
1982
+ ? this.linkResults.map(item => html`
1983
+ <button type="button" class="remarkd-editor__link-option"
1984
+ @click=${() => this.insertLink(item)}>
1985
+ <span class="remarkd-editor__link-option-title">${item.title}</span>
1986
+ <span class="remarkd-editor__link-option-meta">${
1987
+ [kinds[item.kind] ?? item.kind, item.context, item.status === 'published' ? '' : item.status]
1988
+ .filter(Boolean).join(' · ')}</span>
1989
+ </button>`)
1990
+ : html`<div class="remarkd-editor__link-picker-empty">No articles found</div>`}
1991
+ </div>`;
1992
+ }
1993
+
1994
+ private insertLink(item: LinkOption) {
1995
+ const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
1996
+ const range = this.linkSelection;
1997
+ this.linkPickerOpen = false;
1998
+ this.linkSelection = null;
1999
+ this.suppressBlurCommit = false;
2000
+ if (!input || !range) return;
2001
+
2002
+ const [start, end] = range;
2003
+ const selected = input.value.slice(start, end).trim();
2004
+ const markup = contentLinkMarkup(item.ref, selected || item.title);
2005
+ const next = input.value.slice(0, start) + markup + input.value.slice(end);
2006
+
2007
+ this.editingDraft = next;
2008
+ input.value = next;
2009
+ input.setSelectionRange(start + markup.length, start + markup.length);
2010
+ this.editShell = this.computeEditShell(next);
2011
+ this.autosize(input);
2012
+ input.focus({preventScroll: true});
2013
+ this.emit('zn-input');
2014
+ }
2015
+
1774
2016
  private renderImagePicker() {
1775
2017
  return html`
1776
2018
  <div class="remarkd-editor__image-picker">