@kubex/zinc 1.1.94 → 1.1.95
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 +995 -196
- package/dist/vscode.html-custom-data.json +74 -19
- package/dist/web-types.json +147 -37
- package/dist/zn.d.ts +335 -59
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +467 -355
- package/docs/pages/components/page-builder.md +121 -19
- package/docs/pages/components/slash-menu.md +132 -6
- package/docs/pages/components/textarea.md +16 -0
- package/package.json +1 -1
- package/scss/_root.scss +7 -1
- package/src/components/button/button.scss +5 -2
- package/src/components/inline-edit/inline-edit.component.ts +6 -1
- package/src/components/input/input.component.ts +12 -2
- package/src/components/page/page.scss +7 -2
- package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
- package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
- package/src/components/page-builder/page-builder.component.ts +483 -225
- package/src/components/page-builder/page-builder.scss +64 -10
- package/src/components/page-builder/page-builder.test.ts +656 -110
- package/src/components/page-builder/page-tree.test.ts +483 -0
- package/src/components/page-builder/page-tree.ts +329 -0
- package/src/components/page-builder/page.types.ts +75 -7
- package/src/components/remarkd-editor/remarkd-editor.component.ts +198 -9
- package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
- package/src/components/remarkd-editor/remarkd-editor.test.ts +179 -0
- package/src/components/settings-container/settings-container.scss +2 -1
- package/src/components/slash-item/slash-item.component.ts +1 -1
- package/src/components/slash-menu/slash-menu-items.ts +48 -0
- package/src/components/slash-menu/slash-menu.component.ts +134 -27
- package/src/components/slash-menu/slash-menu.scss +90 -12
- package/src/components/slash-menu/slash-menu.test.ts +107 -0
- package/src/components/textarea/textarea.component.ts +12 -2
- package/src/components/textarea/textarea.test.ts +2 -2
- package/src/components/translations/translations.component.ts +5 -1
|
@@ -28,6 +28,7 @@ interface BlockType {
|
|
|
28
28
|
/** Where the caret lands within `prefix`. Defaults to the end. */
|
|
29
29
|
caretOffset?: number;
|
|
30
30
|
image?: boolean;
|
|
31
|
+
include?: boolean;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
interface ImageBlockData {
|
|
@@ -39,6 +40,21 @@ interface ImageBlockData {
|
|
|
39
40
|
height: string;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
interface IncludeBlockData {
|
|
44
|
+
target: string;
|
|
45
|
+
label: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface IncludeOption {
|
|
49
|
+
id: string;
|
|
50
|
+
title: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
scope?: string;
|
|
53
|
+
keywords?: string[];
|
|
54
|
+
languages?: string;
|
|
55
|
+
url?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
const BLOCK_TYPES: BlockType[] = [
|
|
43
59
|
{label: 'Text', icon: 'text@lu', prefix: ''},
|
|
44
60
|
{label: 'Heading 1', icon: 'heading-1@lu', prefix: '# '},
|
|
@@ -49,11 +65,25 @@ const BLOCK_TYPES: BlockType[] = [
|
|
|
49
65
|
{label: 'Warning', icon: 'triangle-alert@lu', prefix: 'WARNING: '},
|
|
50
66
|
{label: 'Code', icon: 'code@lu', prefix: '```\n\n```', caretOffset: 4},
|
|
51
67
|
{label: 'Image', icon: 'image@lu', image: true},
|
|
68
|
+
{label: 'Include', icon: 'blocks@lu', include: true},
|
|
52
69
|
];
|
|
53
70
|
|
|
71
|
+
/** remarkd's include directive on a line of its own: `include::<target>[<label>]`. */
|
|
72
|
+
const INCLUDE_LINE = /^include::([^[]+)\[(.*)]\s*$/;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The marker a document body carries for an embed. Mirrors app-kb's
|
|
76
|
+
* `model.IncludeEmbedMarker`: brackets and newlines in the title would break the
|
|
77
|
+
* directive, so they are replaced rather than escaped.
|
|
78
|
+
*/
|
|
79
|
+
function includeMarker(id: string, title: string): string {
|
|
80
|
+
const label = title.replace(/[\r\n]+/g, ' ').replace(/\[/g, '(').replace(/]/g, ')').trim();
|
|
81
|
+
return `include::${id}[${label}]`;
|
|
82
|
+
}
|
|
83
|
+
|
|
54
84
|
/** The block types as slash menu insertions. Image opens the picker, so it inserts nothing. */
|
|
55
|
-
const SLASH_ITEMS: SlashMenuItem[] = BLOCK_TYPES.map(item => item.image
|
|
56
|
-
? {label: item.label, icon: item.icon, action: 'image'}
|
|
85
|
+
const SLASH_ITEMS: SlashMenuItem[] = BLOCK_TYPES.map(item => item.image || item.include
|
|
86
|
+
? {label: item.label, icon: item.icon, action: item.image ? 'image' : 'include'}
|
|
57
87
|
: {label: item.label, icon: item.icon, value: item.prefix ?? '', caretOffset: item.caretOffset});
|
|
58
88
|
|
|
59
89
|
/**
|
|
@@ -80,6 +110,8 @@ const SLASH_ITEMS: SlashMenuItem[] = BLOCK_TYPES.map(item => item.image
|
|
|
80
110
|
* @csspart raw - The full-document textarea shown in raw source mode.
|
|
81
111
|
* @csspart slash-menu - The `zn-slash-menu` opened by typing "/" in an empty block.
|
|
82
112
|
* @csspart image-controls - The caption / alignment / size panel shown when an image block is clicked.
|
|
113
|
+
* @csspart include - The chip rendered in place of an `include::` directive.
|
|
114
|
+
* @csspart include-picker - The inline Include picker opened from the toolbar or "/include".
|
|
83
115
|
*/
|
|
84
116
|
export default class ZnRemarkdEditor extends ZincElement implements ZincFormControl {
|
|
85
117
|
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
@@ -95,11 +127,14 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
95
127
|
menu: () => this.mountSlashMenu(),
|
|
96
128
|
// The menu only belongs in a block that is nothing but the slash command — a
|
|
97
129
|
// block prefix like "## " is not valid remarkd part-way through a line.
|
|
98
|
-
items: () => this.isSlashBlock()
|
|
130
|
+
items: () => this.isSlashBlock()
|
|
131
|
+
? SLASH_ITEMS.filter(item => item.action !== 'include' || !!this.includeUrl)
|
|
132
|
+
: [],
|
|
99
133
|
onSelect: item => this.handleSlashSelect(item)
|
|
100
134
|
});
|
|
101
135
|
|
|
102
136
|
private editingDraft = '';
|
|
137
|
+
private includeRequest: Promise<IncludeOption[]> | null = null;
|
|
103
138
|
private rawEntryValue = '';
|
|
104
139
|
private suppressValueSync = false;
|
|
105
140
|
private suppressBlurCommit = false;
|
|
@@ -109,6 +144,12 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
109
144
|
|
|
110
145
|
@state() private blocks: string[] = [];
|
|
111
146
|
@state() private editingIndex: number | null = null;
|
|
147
|
+
/**
|
|
148
|
+
* Lists the blocks most recently inserted here above the rest of the slash menu, remembered in
|
|
149
|
+
* `localStorage` under this key. Leave unset to offer no recently used section.
|
|
150
|
+
*/
|
|
151
|
+
@property({attribute: 'slash-recent-key'}) slashRecentKey = '';
|
|
152
|
+
|
|
112
153
|
/** Renders the slash menu only once it has been needed. */
|
|
113
154
|
@state() private hasSlashMenu = false;
|
|
114
155
|
@state() private imagePickerIndex: number | null = null;
|
|
@@ -117,6 +158,9 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
117
158
|
@state() private dragIndex: number | null = null;
|
|
118
159
|
@state() private editShell = '';
|
|
119
160
|
@state() private rawMode = false;
|
|
161
|
+
@state() private includeOptions: IncludeOption[] | null = null;
|
|
162
|
+
@state() private includePickerIndex: number | null = null;
|
|
163
|
+
@state() private includeQuery = '';
|
|
120
164
|
|
|
121
165
|
private pendingDragHandle: HTMLElement | null = null;
|
|
122
166
|
private dragStartX = 0;
|
|
@@ -142,6 +186,13 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
142
186
|
*/
|
|
143
187
|
@property({attribute: 'attachment-url'}) attachmentUrl = '';
|
|
144
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Endpoint listing the Includes this document may embed, as
|
|
191
|
+
* `{"items":[{id,title,description,scope,keywords,languages,url}]}`. Labels the
|
|
192
|
+
* chips rendered for `include::` directives and feeds the include picker.
|
|
193
|
+
*/
|
|
194
|
+
@property({attribute: 'include-url'}) includeUrl = '';
|
|
195
|
+
|
|
145
196
|
/** Adds a toolbar toggle that swaps the block view for the full remarkd source. */
|
|
146
197
|
@property({type: Boolean, attribute: 'allow-raw', reflect: true}) allowRaw = false;
|
|
147
198
|
|
|
@@ -201,6 +252,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
201
252
|
protected firstUpdated(_changedProperties: PropertyValues) {
|
|
202
253
|
super.firstUpdated(_changedProperties);
|
|
203
254
|
this.formControlController.updateValidity();
|
|
255
|
+
if (this.hasIncludeBlock()) void this.loadIncludeOptions();
|
|
204
256
|
}
|
|
205
257
|
|
|
206
258
|
disconnectedCallback() {
|
|
@@ -215,6 +267,14 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
215
267
|
return;
|
|
216
268
|
}
|
|
217
269
|
this.blocks = this.splitBlocks(this.value || '');
|
|
270
|
+
if (this.hasUpdated && this.hasIncludeBlock()) void this.loadIncludeOptions();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
@watch('includeUrl', {waitUntilFirstUpdate: true})
|
|
274
|
+
handleIncludeUrlChange() {
|
|
275
|
+
this.includeRequest = null;
|
|
276
|
+
this.includeOptions = null;
|
|
277
|
+
if (this.hasIncludeBlock()) void this.loadIncludeOptions();
|
|
218
278
|
}
|
|
219
279
|
|
|
220
280
|
/**
|
|
@@ -246,6 +306,12 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
246
306
|
fence = marker;
|
|
247
307
|
continue;
|
|
248
308
|
}
|
|
309
|
+
if (INCLUDE_LINE.test(trimmed)) {
|
|
310
|
+
push();
|
|
311
|
+
current.push(line);
|
|
312
|
+
push();
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
249
315
|
if (trimmed === '') {
|
|
250
316
|
push();
|
|
251
317
|
continue;
|
|
@@ -337,6 +403,13 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
337
403
|
return found ? data : null;
|
|
338
404
|
}
|
|
339
405
|
|
|
406
|
+
/** Parses a block that is nothing but an include directive. */
|
|
407
|
+
private parseIncludeBlock(block: string): IncludeBlockData | null {
|
|
408
|
+
const match = INCLUDE_LINE.exec((block ?? '').trim());
|
|
409
|
+
if (!match) return null;
|
|
410
|
+
return {target: match[1].trim(), label: match[2].trim()};
|
|
411
|
+
}
|
|
412
|
+
|
|
340
413
|
private serializeImageBlock(data: ImageBlockData): string {
|
|
341
414
|
const lines: string[] = [];
|
|
342
415
|
if (data.caption.trim()) lines.push(`.${data.caption.trim()}`);
|
|
@@ -549,14 +622,18 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
549
622
|
|
|
550
623
|
/** Returns false for items the controller should not insert text for. */
|
|
551
624
|
private handleSlashSelect(item: SlashMenuItem): boolean {
|
|
552
|
-
if (item.action !== 'image') return true;
|
|
625
|
+
if (item.action !== 'image' && item.action !== 'include') return true;
|
|
553
626
|
|
|
554
627
|
const index = this.editingIndex ?? this.blocks.length;
|
|
555
628
|
// Let the controller strip the "/…" text first, then drop the empty draft
|
|
556
|
-
// block and run the
|
|
629
|
+
// block and run the picker in its place.
|
|
557
630
|
void this.updateComplete.then(() => {
|
|
558
631
|
this.blur();
|
|
559
|
-
|
|
632
|
+
if (item.action === 'image') {
|
|
633
|
+
this.pickImage(index);
|
|
634
|
+
} else {
|
|
635
|
+
this.pickInclude(index);
|
|
636
|
+
}
|
|
560
637
|
});
|
|
561
638
|
return false;
|
|
562
639
|
}
|
|
@@ -682,9 +759,28 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
682
759
|
|
|
683
760
|
private pickImage(index: number) {
|
|
684
761
|
if (this.disabled || this.readonly) return;
|
|
762
|
+
this.includePickerIndex = null;
|
|
685
763
|
this.imagePickerIndex = index;
|
|
686
764
|
}
|
|
687
765
|
|
|
766
|
+
private pickInclude(index: number) {
|
|
767
|
+
if (this.disabled || this.readonly || !this.includeUrl) return;
|
|
768
|
+
this.imagePickerIndex = null;
|
|
769
|
+
this.includeQuery = '';
|
|
770
|
+
this.includePickerIndex = index;
|
|
771
|
+
void this.loadIncludeOptions();
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private closeIncludePicker = () => {
|
|
775
|
+
this.includePickerIndex = null;
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
private insertInclude(item: IncludeOption) {
|
|
779
|
+
const index = this.includePickerIndex ?? this.blocks.length;
|
|
780
|
+
this.includePickerIndex = null;
|
|
781
|
+
this.addBlockAt(index, includeMarker(item.id, item.title));
|
|
782
|
+
}
|
|
783
|
+
|
|
688
784
|
private closeImagePicker = () => {
|
|
689
785
|
this.imagePickerIndex = null;
|
|
690
786
|
};
|
|
@@ -731,6 +827,29 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
731
827
|
return data.uploadPath;
|
|
732
828
|
}
|
|
733
829
|
|
|
830
|
+
private hasIncludeBlock(): boolean {
|
|
831
|
+
return this.blocks.some(block => this.parseIncludeBlock(block) !== null);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Fetches the include list once; every later caller shares the same promise. */
|
|
835
|
+
private loadIncludeOptions(): Promise<IncludeOption[]> {
|
|
836
|
+
if (!this.includeUrl) return Promise.resolve([]);
|
|
837
|
+
if (!this.includeRequest) {
|
|
838
|
+
this.includeRequest = fetch(this.includeUrl, {headers: {Accept: 'application/json'}})
|
|
839
|
+
.then(res => res.ok ? res.json() as Promise<{items?: IncludeOption[]}> : {items: []})
|
|
840
|
+
.then(data => data.items ?? [])
|
|
841
|
+
.catch(error => {
|
|
842
|
+
console.error('[zn-remarkd-editor] include list failed', error);
|
|
843
|
+
return [] as IncludeOption[];
|
|
844
|
+
})
|
|
845
|
+
.then(items => {
|
|
846
|
+
this.includeOptions = items;
|
|
847
|
+
return items;
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
return this.includeRequest;
|
|
851
|
+
}
|
|
852
|
+
|
|
734
853
|
private autosize(input: HTMLTextAreaElement) {
|
|
735
854
|
input.style.height = 'auto';
|
|
736
855
|
input.style.height = `${input.scrollHeight}px`;
|
|
@@ -796,6 +915,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
796
915
|
const index = this.editingIndex !== null ? this.commitEdit() : this.blocks.length;
|
|
797
916
|
if (item.image) {
|
|
798
917
|
this.pickImage(index);
|
|
918
|
+
} else if (item.include) {
|
|
919
|
+
this.pickInclude(index);
|
|
799
920
|
} else {
|
|
800
921
|
this.insertDraftBlock(index, item.prefix ?? '');
|
|
801
922
|
}
|
|
@@ -867,6 +988,32 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
867
988
|
</div>`;
|
|
868
989
|
}
|
|
869
990
|
|
|
991
|
+
private renderIncludeChip(data: IncludeBlockData) {
|
|
992
|
+
const option = this.includeOptions?.find(item => item.id === data.target);
|
|
993
|
+
// A path-like target is remarkd's file include, which this editor cannot
|
|
994
|
+
// resolve and must not call broken.
|
|
995
|
+
const filePath = /[/.]/.test(data.target);
|
|
996
|
+
const pending = !this.includeUrl || this.includeOptions === null;
|
|
997
|
+
const missing = !option && !pending && !filePath;
|
|
998
|
+
return html`
|
|
999
|
+
<div part="include"
|
|
1000
|
+
class=${classMap({
|
|
1001
|
+
'remarkd-editor__include': true,
|
|
1002
|
+
'remarkd-editor__include--missing': missing,
|
|
1003
|
+
})}>
|
|
1004
|
+
<zn-icon src=${missing ? 'triangle-alert@lu' : 'blocks@lu'} size="16"></zn-icon>
|
|
1005
|
+
<span class="remarkd-editor__include-title">${option?.title || data.label || data.target}</span>
|
|
1006
|
+
${option?.scope ? html`
|
|
1007
|
+
<span class="remarkd-editor__include-scope">${option.scope}</span>` : ''}
|
|
1008
|
+
<span class="remarkd-editor__include-meta">${missing
|
|
1009
|
+
? `Include not found · ${data.target}`
|
|
1010
|
+
: !option && filePath ? 'File include' : 'Included content'}</span>
|
|
1011
|
+
${option?.url ? html`
|
|
1012
|
+
<a class="remarkd-editor__include-link" href=${option.url} target="_blank" rel="noreferrer"
|
|
1013
|
+
@click=${(e: MouseEvent) => e.stopPropagation()}>Open</a>` : ''}
|
|
1014
|
+
</div>`;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
870
1017
|
private renderBlock(block: string, index: number) {
|
|
871
1018
|
if (this.editingIndex === index) {
|
|
872
1019
|
if (this.imageEdit) {
|
|
@@ -890,6 +1037,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
890
1037
|
</div>`;
|
|
891
1038
|
}
|
|
892
1039
|
|
|
1040
|
+
const include = this.parseIncludeBlock(block);
|
|
893
1041
|
return html`
|
|
894
1042
|
<div part="block"
|
|
895
1043
|
class=${classMap({
|
|
@@ -914,7 +1062,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
914
1062
|
tooltip="Delete block"
|
|
915
1063
|
@click=${() => this.deleteBlock(index)}></zn-button>`}
|
|
916
1064
|
<div part="rendered" class="remarkd-editor__rendered remarkd-rendered"
|
|
917
|
-
@click=${(e: MouseEvent) => this.handleRenderedClick(e, index)}>${
|
|
1065
|
+
@click=${(e: MouseEvent) => this.handleRenderedClick(e, index)}>${
|
|
1066
|
+
include ? this.renderIncludeChip(include) : unsafeHTML(remarkdParse(block))}
|
|
918
1067
|
</div>
|
|
919
1068
|
</div>`;
|
|
920
1069
|
}
|
|
@@ -932,7 +1081,9 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
932
1081
|
@drop=${this.handleDrop}>
|
|
933
1082
|
${editable ? html`
|
|
934
1083
|
<div part="toolbar" class="remarkd-editor__toolbar">
|
|
935
|
-
${this.rawMode ? '' : BLOCK_TYPES
|
|
1084
|
+
${this.rawMode ? '' : BLOCK_TYPES
|
|
1085
|
+
.filter(item => !item.include || !!this.includeUrl)
|
|
1086
|
+
.map(item => html`
|
|
936
1087
|
<zn-button type="button" icon-button plain icon=${item.icon} icon-size="18"
|
|
937
1088
|
tooltip=${item.label}
|
|
938
1089
|
@click=${() => this.handleToolbarInsert(item)}></zn-button>`)}
|
|
@@ -954,7 +1105,10 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
954
1105
|
</div>`}
|
|
955
1106
|
</div>
|
|
956
1107
|
${this.hasSlashMenu ? html`
|
|
957
|
-
<zn-slash-menu
|
|
1108
|
+
<zn-slash-menu
|
|
1109
|
+
part="slash-menu"
|
|
1110
|
+
heading="Insert block"
|
|
1111
|
+
recent-key=${this.slashRecentKey}></zn-slash-menu>` : ''}
|
|
958
1112
|
<textarea class="remarkd-editor__validation"
|
|
959
1113
|
.value=${this.value}
|
|
960
1114
|
?required=${this.required}
|
|
@@ -980,9 +1134,44 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
980
1134
|
if (this.imagePickerIndex !== null) {
|
|
981
1135
|
views.splice(this.imagePickerIndex, 0, this.renderImagePicker());
|
|
982
1136
|
}
|
|
1137
|
+
if (this.includePickerIndex !== null) {
|
|
1138
|
+
views.splice(this.includePickerIndex, 0, this.renderIncludePicker());
|
|
1139
|
+
}
|
|
983
1140
|
return views;
|
|
984
1141
|
}
|
|
985
1142
|
|
|
1143
|
+
private renderIncludePicker() {
|
|
1144
|
+
const filter = this.includeQuery.trim().toLowerCase();
|
|
1145
|
+
const items = (this.includeOptions ?? []).filter(item => !filter || [
|
|
1146
|
+
item.title, item.description ?? '', (item.keywords ?? []).join(' '),
|
|
1147
|
+
].some(field => field.toLowerCase().includes(filter)));
|
|
1148
|
+
return html`
|
|
1149
|
+
<div part="include-picker" class="remarkd-editor__include-picker">
|
|
1150
|
+
<div class="remarkd-editor__include-picker-head">
|
|
1151
|
+
<input class="remarkd-editor__include-filter"
|
|
1152
|
+
placeholder="Find an Include"
|
|
1153
|
+
.value=${this.includeQuery}
|
|
1154
|
+
@input=${(e: Event) => {
|
|
1155
|
+
this.includeQuery = (e.target as HTMLInputElement).value;
|
|
1156
|
+
}}>
|
|
1157
|
+
<zn-button type="button" icon-button="small" plain icon="x@lu"
|
|
1158
|
+
tooltip="Cancel" @click=${this.closeIncludePicker}></zn-button>
|
|
1159
|
+
</div>
|
|
1160
|
+
${this.includeOptions === null
|
|
1161
|
+
? html`<div class="remarkd-editor__include-picker-empty">Loading…</div>`
|
|
1162
|
+
: items.length
|
|
1163
|
+
? items.map(item => html`
|
|
1164
|
+
<button type="button" class="remarkd-editor__include-option"
|
|
1165
|
+
?disabled=${!item.languages}
|
|
1166
|
+
@click=${() => this.insertInclude(item)}>
|
|
1167
|
+
<span class="remarkd-editor__include-option-title">${item.title}</span>
|
|
1168
|
+
<span class="remarkd-editor__include-option-meta">${item.scope ?? ''}${
|
|
1169
|
+
item.languages ? ` · ${item.languages}` : ' · No content'}</span>
|
|
1170
|
+
</button>`)
|
|
1171
|
+
: html`<div class="remarkd-editor__include-picker-empty">No Includes found</div>`}
|
|
1172
|
+
</div>`;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
986
1175
|
private renderImagePicker() {
|
|
987
1176
|
return html`
|
|
988
1177
|
<div class="remarkd-editor__image-picker">
|
|
@@ -224,6 +224,87 @@
|
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
.remarkd-editor__include {
|
|
228
|
+
display: flex;
|
|
229
|
+
align-items: center;
|
|
230
|
+
gap: var(--zn-spacing-small);
|
|
231
|
+
padding: var(--zn-spacing-small);
|
|
232
|
+
border: 1px dashed rgb(var(--zn-border-color));
|
|
233
|
+
border-radius: var(--zn-border-radius);
|
|
234
|
+
background: var(--zn-color-neutral-0, white);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.remarkd-editor__include-title {
|
|
238
|
+
font-weight: 600;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.remarkd-editor__include-meta {
|
|
242
|
+
color: var(--zn-color-neutral-400, #9aa4b2);
|
|
243
|
+
font-size: var(--zn-font-size-small);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.remarkd-editor__include--missing {
|
|
247
|
+
border-style: solid;
|
|
248
|
+
border-color: rgb(var(--zn-color-error));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.remarkd-editor__include-scope {
|
|
252
|
+
color: var(--zn-color-neutral-400, #9aa4b2);
|
|
253
|
+
font-size: var(--zn-font-size-small);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.remarkd-editor__include-link {
|
|
257
|
+
margin-left: auto;
|
|
258
|
+
font-size: var(--zn-font-size-small);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.remarkd-editor__include-picker {
|
|
262
|
+
display: flex;
|
|
263
|
+
flex-direction: column;
|
|
264
|
+
gap: var(--zn-spacing-x-small);
|
|
265
|
+
padding: var(--zn-spacing-small);
|
|
266
|
+
border: 1px solid rgb(var(--zn-border-color));
|
|
267
|
+
border-radius: var(--zn-border-radius);
|
|
268
|
+
background: var(--zn-color-neutral-0, white);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.remarkd-editor__include-picker-head {
|
|
272
|
+
display: flex;
|
|
273
|
+
align-items: center;
|
|
274
|
+
gap: var(--zn-spacing-x-small);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.remarkd-editor__include-filter {
|
|
278
|
+
flex: 1 1 auto;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.remarkd-editor__include-option {
|
|
282
|
+
display: flex;
|
|
283
|
+
justify-content: space-between;
|
|
284
|
+
gap: var(--zn-spacing-small);
|
|
285
|
+
padding: var(--zn-spacing-x-small) var(--zn-spacing-small);
|
|
286
|
+
border: 0;
|
|
287
|
+
border-radius: var(--zn-border-radius);
|
|
288
|
+
background: none;
|
|
289
|
+
cursor: pointer;
|
|
290
|
+
text-align: left;
|
|
291
|
+
|
|
292
|
+
&:hover:not(:disabled) {
|
|
293
|
+
background: var(--zn-color-neutral-100);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
&:disabled {
|
|
297
|
+
cursor: default;
|
|
298
|
+
opacity: 0.6;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.remarkd-editor__include-option-meta,
|
|
303
|
+
.remarkd-editor__include-picker-empty {
|
|
304
|
+
color: var(--zn-color-neutral-400, #9aa4b2);
|
|
305
|
+
font-size: var(--zn-font-size-small);
|
|
306
|
+
}
|
|
307
|
+
|
|
227
308
|
.remarkd-editor__image-controls {
|
|
228
309
|
display: flex;
|
|
229
310
|
flex-direction: column;
|
|
@@ -282,4 +282,183 @@ Second"></zn-remarkd-editor>`);
|
|
|
282
282
|
|
|
283
283
|
expect(fired).to.be.true;
|
|
284
284
|
});
|
|
285
|
+
it('should keep an include directive in a block of its own', async () => {
|
|
286
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
287
|
+
<zn-remarkd-editor value="Intro
|
|
288
|
+
include::inc-1[Payment Terms]
|
|
289
|
+
Outro"></zn-remarkd-editor>`);
|
|
290
|
+
|
|
291
|
+
const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block');
|
|
292
|
+
expect(blocks.length).to.equal(3);
|
|
293
|
+
expect(blocks[1].querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Payment Terms');
|
|
294
|
+
expect(blocks[0].querySelector('.remarkd-editor__include')).to.not.exist;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should leave an include directive inside a fence alone', async () => {
|
|
298
|
+
const fenced = '```\ninclude::inc-1[Payment Terms]\n```';
|
|
299
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
300
|
+
<zn-remarkd-editor value=${fenced}></zn-remarkd-editor>`);
|
|
301
|
+
|
|
302
|
+
expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1);
|
|
303
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__include')).to.not.exist;
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should label an include chip from the marker when no list is configured', async () => {
|
|
307
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
308
|
+
<zn-remarkd-editor value="include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
|
|
309
|
+
|
|
310
|
+
const chip = el.shadowRoot!.querySelector('.remarkd-editor__include')!;
|
|
311
|
+
expect(chip.querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Payment Terms');
|
|
312
|
+
expect(chip.classList.contains('remarkd-editor__include--missing')).to.be.false;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Ordering an embed within the content is the whole point of the chip: the
|
|
316
|
+
// block chrome's drag handle has to move it like any other block.
|
|
317
|
+
it('should reorder an include block by dragging its handle', async () => {
|
|
318
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
319
|
+
<zn-remarkd-editor value="Intro
|
|
320
|
+
|
|
321
|
+
include::inc-1[Payment Terms]"></zn-remarkd-editor>`);
|
|
322
|
+
|
|
323
|
+
const first = el.shadowRoot!.querySelectorAll('.remarkd-editor__block')[0].getBoundingClientRect();
|
|
324
|
+
const handle = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__drag-handle')[1];
|
|
325
|
+
const target = first.top + 1;
|
|
326
|
+
|
|
327
|
+
handle.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, button: 0, buttons: 1, clientX: 0, clientY: 0}));
|
|
328
|
+
document.dispatchEvent(new PointerEvent('pointermove', {bubbles: true, buttons: 1, clientX: 0, clientY: target}));
|
|
329
|
+
document.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, buttons: 0, clientX: 0, clientY: target}));
|
|
330
|
+
await el.updateComplete;
|
|
331
|
+
|
|
332
|
+
expect(el.value).to.equal('include::inc-1[Payment Terms]\n\nIntro');
|
|
333
|
+
});
|
|
334
|
+
/** Serves one include list to the editor, and restores fetch when the test ends. */
|
|
335
|
+
function stubIncludeList(items: unknown[]): void {
|
|
336
|
+
const original = window.fetch;
|
|
337
|
+
window.fetch = () => Promise.resolve(new Response(JSON.stringify({items}),
|
|
338
|
+
{headers: {'Content-Type': 'application/json'}}));
|
|
339
|
+
afterEach(() => {
|
|
340
|
+
window.fetch = original;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
it('should label an include chip from the fetched list', async () => {
|
|
345
|
+
stubIncludeList([{id: 'inc-1', title: 'Refund Policy', scope: 'Global',
|
|
346
|
+
languages: 'English', url: '/kb/kb1/includes/inc-1'}]);
|
|
347
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
348
|
+
<zn-remarkd-editor include-url="/options"
|
|
349
|
+
value="include::inc-1[Stale Title]"></zn-remarkd-editor>`);
|
|
350
|
+
|
|
351
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'),
|
|
352
|
+
'the include list never resolved');
|
|
353
|
+
const chip = el.shadowRoot!.querySelector('.remarkd-editor__include')!;
|
|
354
|
+
expect(chip.querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Refund Policy');
|
|
355
|
+
expect(chip.querySelector('.remarkd-editor__include-scope')!.textContent).to.contain('Global');
|
|
356
|
+
expect(chip.querySelector<HTMLAnchorElement>('.remarkd-editor__include-link')!.getAttribute('href'))
|
|
357
|
+
.to.equal('/kb/kb1/includes/inc-1');
|
|
358
|
+
expect(chip.classList.contains('remarkd-editor__include--missing')).to.be.false;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should flag an include the list does not know', async () => {
|
|
362
|
+
stubIncludeList([{id: 'inc-1', title: 'Refund Policy'}]);
|
|
363
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
364
|
+
<zn-remarkd-editor include-url="/options"
|
|
365
|
+
value="include::inc-9[Archived]"></zn-remarkd-editor>`);
|
|
366
|
+
|
|
367
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include--missing'),
|
|
368
|
+
'the missing state never rendered');
|
|
369
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__include-meta')!.textContent).to.contain('inc-9');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// remarkd's file include shares the syntax, so an unknown path is not broken.
|
|
373
|
+
it('should not flag a file include the list does not know', async () => {
|
|
374
|
+
stubIncludeList([{id: 'inc-1', title: 'Refund Policy'}]);
|
|
375
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
376
|
+
<zn-remarkd-editor include-url="/options"
|
|
377
|
+
value="include::partials/legal.adoc[Legal]"></zn-remarkd-editor>`);
|
|
378
|
+
|
|
379
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include'),
|
|
380
|
+
'the chip never rendered');
|
|
381
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__include--missing')).to.not.exist;
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should not request the include list for a document with no embeds', async () => {
|
|
385
|
+
const original = window.fetch;
|
|
386
|
+
let requests = 0;
|
|
387
|
+
window.fetch = () => {
|
|
388
|
+
requests++;
|
|
389
|
+
return Promise.resolve(new Response('{"items":[]}'));
|
|
390
|
+
};
|
|
391
|
+
try {
|
|
392
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
393
|
+
<zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
|
|
394
|
+
await el.updateComplete;
|
|
395
|
+
expect(requests).to.equal(0);
|
|
396
|
+
} finally {
|
|
397
|
+
window.fetch = original;
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
it('should insert an include marker from the toolbar picker', async () => {
|
|
401
|
+
stubIncludeList([{id: 'inc-1', title: 'Refund Policy', scope: 'Global', languages: 'English'}]);
|
|
402
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
403
|
+
<zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
|
|
404
|
+
|
|
405
|
+
const buttons = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__toolbar zn-button');
|
|
406
|
+
const includeButton = buttons[buttons.length - 1];
|
|
407
|
+
includeButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
408
|
+
|
|
409
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-option'),
|
|
410
|
+
'the include picker never listed anything');
|
|
411
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__include-option')!.click();
|
|
412
|
+
await el.updateComplete;
|
|
413
|
+
|
|
414
|
+
expect(el.value).to.equal('# Title\n\ninclude::inc-1[Refund Policy]');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should swap the slash command for the include picker when Include is chosen', async () => {
|
|
418
|
+
stubIncludeList([{id: 'inc-1', title: 'Refund Policy', languages: 'English'}]);
|
|
419
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
420
|
+
<zn-remarkd-editor include-url="/options" value="Hello"></zn-remarkd-editor>`);
|
|
421
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
422
|
+
await el.updateComplete;
|
|
423
|
+
|
|
424
|
+
const input = typeInBlock(el, '/include');
|
|
425
|
+
await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
|
|
426
|
+
|
|
427
|
+
input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
|
|
428
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-picker'),
|
|
429
|
+
'the include picker never opened');
|
|
430
|
+
|
|
431
|
+
// The "/include" draft is dropped rather than committed as a block.
|
|
432
|
+
expect(el.value).to.equal('');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('should filter the include picker', async () => {
|
|
436
|
+
stubIncludeList([
|
|
437
|
+
{id: 'inc-1', title: 'Refund Policy', keywords: ['money'], languages: 'English'},
|
|
438
|
+
{id: 'inc-2', title: 'Shipping', languages: 'English'},
|
|
439
|
+
]);
|
|
440
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
441
|
+
<zn-remarkd-editor include-url="/options" value="# Title"></zn-remarkd-editor>`);
|
|
442
|
+
const buttons = el.shadowRoot!.querySelectorAll<HTMLElement>('.remarkd-editor__toolbar zn-button');
|
|
443
|
+
buttons[buttons.length - 1].dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
444
|
+
await waitUntil(() => el.shadowRoot!.querySelectorAll('.remarkd-editor__include-option').length === 2,
|
|
445
|
+
'the include picker never listed both');
|
|
446
|
+
|
|
447
|
+
const filter = el.shadowRoot!.querySelector<HTMLInputElement>('.remarkd-editor__include-filter')!;
|
|
448
|
+
filter.value = 'money';
|
|
449
|
+
filter.dispatchEvent(new Event('input', {bubbles: true}));
|
|
450
|
+
await el.updateComplete;
|
|
451
|
+
|
|
452
|
+
const options = el.shadowRoot!.querySelectorAll('.remarkd-editor__include-option');
|
|
453
|
+
expect(options.length).to.equal(1);
|
|
454
|
+
expect(options[0].textContent).to.contain('Refund Policy');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('should offer no include entry points without an include-url', async () => {
|
|
458
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
459
|
+
<zn-remarkd-editor value="# Title"></zn-remarkd-editor>`);
|
|
460
|
+
const tooltips = Array.from(el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button'))
|
|
461
|
+
.map(button => button.getAttribute('tooltip'));
|
|
462
|
+
expect(tooltips).to.not.contain('Include');
|
|
463
|
+
});
|
|
285
464
|
});
|
|
@@ -105,7 +105,8 @@ zn-button {
|
|
|
105
105
|
border-bottom-width: 0;
|
|
106
106
|
border-radius: 0 0 0 var(--zn-border-radius);
|
|
107
107
|
transition: grid-template-rows 0.22s ease, border-bottom-width 0s linear 0.22s;
|
|
108
|
-
|
|
108
|
+
// PERF: grid-template-rows is not a compositable property, so will-change
|
|
109
|
+
// cannot help it — it only promoted a layer per settings container for free.
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
&--open &__panel {
|
|
@@ -2,7 +2,7 @@ import {html, unsafeCSS} from 'lit';
|
|
|
2
2
|
import {property} from 'lit/decorators.js';
|
|
3
3
|
import ZincElement from '../../internal/zinc-element';
|
|
4
4
|
import type {CSSResultGroup} from 'lit';
|
|
5
|
-
import type {SlashMenuItem} from '../slash-menu
|
|
5
|
+
import type {SlashMenuItem} from '../slash-menu';
|
|
6
6
|
|
|
7
7
|
import styles from './slash-item.scss';
|
|
8
8
|
|