@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.
- package/dist/custom-elements.json +1070 -819
- package/dist/vscode.html-custom-data.json +54 -49
- package/dist/web-types.json +125 -115
- package/dist/zn.d.ts +58 -6
- package/dist/zn.min.js +334 -317
- package/docs/pages/components/form-group.md +2 -0
- package/package.json +1 -1
- package/src/components/form-group/form-group.component.ts +94 -19
- package/src/components/form-group/form-group.test.ts +20 -0
- package/src/components/remarkd-editor/actions.ts +2 -1
- package/src/components/remarkd-editor/remarkd-editor.component.ts +251 -9
- package/src/components/remarkd-editor/remarkd-editor.scss +49 -0
- package/src/components/remarkd-editor/remarkd-editor.test.ts +329 -5
- package/src/declaration.d.ts +22 -0
|
@@ -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
|
@@ -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
|
|
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.
|
|
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.
|
|
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
|
|
103
|
+
* slideout, say — the label never moves, so it is moved against the real scroller instead.
|
|
102
104
|
*/
|
|
103
|
-
private
|
|
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
|
-
|
|
112
|
-
const anchored = !anchor || scroller === anchor;
|
|
115
|
+
if (!anchor) return;
|
|
113
116
|
|
|
114
|
-
this.
|
|
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
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
this.scrollTarget(this.tracked)
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
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
|
|
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">
|