@kubex/zinc 1.1.118 → 1.1.119

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.
@@ -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">
@@ -485,3 +485,52 @@
485
485
  border: 0;
486
486
  padding: 0;
487
487
  }
488
+
489
+ .remarkd-editor__link-picker {
490
+ display: flex;
491
+ flex-direction: column;
492
+ gap: var(--zn-spacing-x-small);
493
+ margin-top: var(--zn-spacing-x-small);
494
+ padding: var(--zn-spacing-small);
495
+ border: 1px solid rgb(var(--zn-border-color));
496
+ border-radius: var(--zn-border-radius);
497
+ background: var(--zn-color-neutral-0, white);
498
+ }
499
+
500
+ .remarkd-editor__link-picker-head {
501
+ display: flex;
502
+ align-items: center;
503
+ gap: var(--zn-spacing-x-small);
504
+ }
505
+
506
+ .remarkd-editor__link-filter {
507
+ flex: 1 1 auto;
508
+ }
509
+
510
+ .remarkd-editor__link-option {
511
+ display: flex;
512
+ justify-content: space-between;
513
+ gap: var(--zn-spacing-small);
514
+ padding: var(--zn-spacing-x-small) var(--zn-spacing-small);
515
+ border: 0;
516
+ border-radius: var(--zn-border-radius);
517
+ background: none;
518
+ cursor: pointer;
519
+ text-align: left;
520
+
521
+ &:hover {
522
+ background: var(--zn-color-neutral-100);
523
+ }
524
+ }
525
+
526
+ .remarkd-editor__link-option-meta,
527
+ .remarkd-editor__link-picker-empty {
528
+ color: var(--zn-color-neutral-400, #9aa4b2);
529
+ font-size: var(--zn-font-size-small);
530
+ }
531
+
532
+ .remarkd-editor__link--missing {
533
+ text-decoration-line: underline;
534
+ text-decoration-style: wavy;
535
+ text-decoration-color: rgb(var(--zn-color-error));
536
+ }