@kubex/zinc 1.1.80 → 1.1.81
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 +392 -122
- package/dist/vscode.html-custom-data.json +40 -15
- package/dist/web-types.json +109 -29
- package/dist/zn.d.ts +372 -297
- package/dist/zn.min.js +282 -265
- package/docs/pages/components/inline-edit.md +3 -2
- package/docs/pages/components/input.md +21 -0
- package/docs/pages/components/remarkd-editor.md +43 -0
- package/docs/pages/components/slash-menu.md +7 -14
- package/docs/pages/components/translations.md +3 -3
- package/package.json +1 -1
- package/src/components/inline-edit/inline-edit.component.ts +7 -2
- package/src/components/input/input.component.ts +143 -0
- package/src/components/input/input.test.ts +113 -1
- package/src/components/remarkd-editor/remarkd-editor.component.ts +157 -95
- package/src/components/remarkd-editor/remarkd-editor.scss +24 -36
- package/src/components/remarkd-editor/remarkd-editor.test.ts +110 -6
- package/src/components/slash-menu/slash-menu.component.ts +23 -1
- package/src/components/translations/translations.component.ts +1 -1
|
@@ -4,9 +4,12 @@ import {defaultValue} from "../../internal/default-value";
|
|
|
4
4
|
import {FormControlController} from "../../internal/form";
|
|
5
5
|
import {property, query, state} from 'lit/decorators.js';
|
|
6
6
|
import {parse as remarkdParse} from "remarkd-js";
|
|
7
|
+
import {SlashMenuController} from "../slash-menu/slash-menu-controller";
|
|
7
8
|
import {unsafeHTML} from "lit/directives/unsafe-html.js";
|
|
8
9
|
import {watch} from "../../internal/watch";
|
|
9
10
|
import ZincElement from '../../internal/zinc-element';
|
|
11
|
+
import ZnSlashMenu from "../slash-menu";
|
|
12
|
+
import type {SlashMenuItem} from "../slash-menu/slash-menu-items";
|
|
10
13
|
import type {ZincFormControl} from '../../internal/zinc-element';
|
|
11
14
|
import type ZnFile from "../file";
|
|
12
15
|
|
|
@@ -22,6 +25,8 @@ interface BlockType {
|
|
|
22
25
|
label: string;
|
|
23
26
|
icon: string;
|
|
24
27
|
prefix?: string;
|
|
28
|
+
/** Where the caret lands within `prefix`. Defaults to the end. */
|
|
29
|
+
caretOffset?: number;
|
|
25
30
|
image?: boolean;
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -42,10 +47,15 @@ const BLOCK_TYPES: BlockType[] = [
|
|
|
42
47
|
{label: 'Note', icon: 'info@lu', prefix: 'NOTE: '},
|
|
43
48
|
{label: 'Tip', icon: 'lightbulb@lu', prefix: 'TIP: '},
|
|
44
49
|
{label: 'Warning', icon: 'triangle-alert@lu', prefix: 'WARNING: '},
|
|
45
|
-
{label: 'Code', icon: 'code@lu', prefix: '```\n\n```'},
|
|
50
|
+
{label: 'Code', icon: 'code@lu', prefix: '```\n\n```', caretOffset: 4},
|
|
46
51
|
{label: 'Image', icon: 'image@lu', image: true},
|
|
47
52
|
];
|
|
48
53
|
|
|
54
|
+
/** 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'}
|
|
57
|
+
: {label: item.label, icon: item.icon, value: item.prefix ?? '', caretOffset: item.caretOffset});
|
|
58
|
+
|
|
49
59
|
/**
|
|
50
60
|
* @summary A Notion-style block editor for remarkd content. Blocks render inline; click one to edit its source.
|
|
51
61
|
* @documentation https://zinc.style/components/remarkd-editor
|
|
@@ -56,41 +66,57 @@ const BLOCK_TYPES: BlockType[] = [
|
|
|
56
66
|
* @dependency zn-button-group
|
|
57
67
|
* @dependency zn-icon
|
|
58
68
|
* @dependency zn-file
|
|
69
|
+
* @dependency zn-slash-menu
|
|
59
70
|
*
|
|
60
71
|
* @event zn-input - Emitted on each keystroke while editing a block.
|
|
61
72
|
* @event zn-change - Emitted when a block edit is committed and the value changes.
|
|
62
73
|
*
|
|
63
74
|
* @csspart base - The component's base wrapper.
|
|
64
75
|
* @csspart toolbar - The always-visible block-insert toolbar.
|
|
76
|
+
* @csspart raw-toggle - The button that switches between the block view and the raw source view.
|
|
65
77
|
* @csspart block - A rendered block wrapper.
|
|
66
78
|
* @csspart rendered - The rendered remarkd output of a block.
|
|
67
79
|
* @csspart input - The textarea shown while editing a block.
|
|
68
|
-
* @csspart
|
|
80
|
+
* @csspart raw - The full-document textarea shown in raw source mode.
|
|
81
|
+
* @csspart slash-menu - The `zn-slash-menu` opened by typing "/" in an empty block.
|
|
69
82
|
* @csspart image-controls - The caption / alignment / size panel shown when an image block is clicked.
|
|
70
83
|
*/
|
|
71
84
|
export default class ZnRemarkdEditor extends ZincElement implements ZincFormControl {
|
|
72
85
|
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
86
|
+
static dependencies = {
|
|
87
|
+
'zn-slash-menu': ZnSlashMenu
|
|
88
|
+
};
|
|
73
89
|
|
|
74
90
|
private readonly formControlController = new FormControlController(this, {
|
|
75
91
|
assumeInteractionOn: ['zn-input', 'zn-change'],
|
|
76
92
|
});
|
|
77
93
|
|
|
94
|
+
private readonly slashController = new SlashMenuController(this, {
|
|
95
|
+
menu: () => this.mountSlashMenu(),
|
|
96
|
+
// The menu only belongs in a block that is nothing but the slash command — a
|
|
97
|
+
// block prefix like "## " is not valid remarkd part-way through a line.
|
|
98
|
+
items: () => this.isSlashBlock() ? SLASH_ITEMS : [],
|
|
99
|
+
onSelect: item => this.handleSlashSelect(item)
|
|
100
|
+
});
|
|
101
|
+
|
|
78
102
|
private editingDraft = '';
|
|
103
|
+
private rawEntryValue = '';
|
|
79
104
|
private suppressValueSync = false;
|
|
80
105
|
private suppressBlurCommit = false;
|
|
81
106
|
|
|
82
107
|
@query('.remarkd-editor__validation') private validationInput: HTMLTextAreaElement;
|
|
108
|
+
@query('zn-slash-menu') private slashMenuElement: ZnSlashMenu | null;
|
|
83
109
|
|
|
84
110
|
@state() private blocks: string[] = [];
|
|
85
111
|
@state() private editingIndex: number | null = null;
|
|
86
|
-
|
|
87
|
-
@state() private
|
|
88
|
-
@state() private slashActiveIndex = 0;
|
|
112
|
+
/** Renders the slash menu only once it has been needed. */
|
|
113
|
+
@state() private hasSlashMenu = false;
|
|
89
114
|
@state() private imagePickerIndex: number | null = null;
|
|
90
115
|
@state() private imageEdit: ImageBlockData | null = null;
|
|
91
116
|
@state() private dropIndicator: number | null = null;
|
|
92
117
|
@state() private dragIndex: number | null = null;
|
|
93
118
|
@state() private editShell = '';
|
|
119
|
+
@state() private rawMode = false;
|
|
94
120
|
|
|
95
121
|
private pendingDragHandle: HTMLElement | null = null;
|
|
96
122
|
private dragStartX = 0;
|
|
@@ -116,6 +142,9 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
116
142
|
*/
|
|
117
143
|
@property({attribute: 'attachment-url'}) attachmentUrl = '';
|
|
118
144
|
|
|
145
|
+
/** Adds a toolbar toggle that swaps the block view for the full remarkd source. */
|
|
146
|
+
@property({type: Boolean, attribute: 'allow-raw', reflect: true}) allowRaw = false;
|
|
147
|
+
|
|
119
148
|
/** Makes the editor required for form submission. */
|
|
120
149
|
@property({type: Boolean, reflect: true}) required = false;
|
|
121
150
|
|
|
@@ -152,6 +181,10 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
152
181
|
|
|
153
182
|
/** Starts editing the first block, or a new block if the document is empty. */
|
|
154
183
|
focus() {
|
|
184
|
+
if (this.rawMode) {
|
|
185
|
+
this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__raw')?.focus();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
155
188
|
if (this.blocks.length) {
|
|
156
189
|
this.startEdit(0);
|
|
157
190
|
} else {
|
|
@@ -159,9 +192,10 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
159
192
|
}
|
|
160
193
|
}
|
|
161
194
|
|
|
162
|
-
/** Commits any in-progress block edit. */
|
|
195
|
+
/** Commits any in-progress block or raw edit. */
|
|
163
196
|
blur() {
|
|
164
|
-
this.
|
|
197
|
+
const selector = this.rawMode ? '.remarkd-editor__raw' : '.remarkd-editor__input';
|
|
198
|
+
this.shadowRoot?.querySelector<HTMLTextAreaElement>(selector)?.blur();
|
|
165
199
|
}
|
|
166
200
|
|
|
167
201
|
protected firstUpdated(_changedProperties: PropertyValues) {
|
|
@@ -262,7 +296,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
262
296
|
if (image) {
|
|
263
297
|
this.editingIndex = index;
|
|
264
298
|
this.imageEdit = image;
|
|
265
|
-
this.
|
|
299
|
+
this.slashController.close();
|
|
266
300
|
return;
|
|
267
301
|
}
|
|
268
302
|
|
|
@@ -339,7 +373,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
339
373
|
this.editingIndex = index;
|
|
340
374
|
this.imageEdit = null;
|
|
341
375
|
this.editShell = this.computeEditShell(this.editingDraft);
|
|
342
|
-
this.
|
|
376
|
+
this.slashController.close();
|
|
343
377
|
void this.focusInput();
|
|
344
378
|
}
|
|
345
379
|
|
|
@@ -404,6 +438,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
404
438
|
await this.updateComplete;
|
|
405
439
|
const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
|
|
406
440
|
if (input) {
|
|
441
|
+
// Each edit renders its own textarea, so the slash menu is re-pointed at it.
|
|
442
|
+
this.slashController.attach(input);
|
|
407
443
|
this.autosize(input);
|
|
408
444
|
input.focus();
|
|
409
445
|
input.setSelectionRange(input.value.length, input.value.length);
|
|
@@ -452,7 +488,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
452
488
|
this.closeImageEdit();
|
|
453
489
|
return index + 1;
|
|
454
490
|
}
|
|
455
|
-
this.
|
|
491
|
+
this.slashController.close();
|
|
456
492
|
if (this.editingIndex === null) return this.blocks.length;
|
|
457
493
|
const index = this.editingIndex;
|
|
458
494
|
const parts = this.splitBlocks(this.editingDraft);
|
|
@@ -463,11 +499,6 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
463
499
|
return index + parts.length;
|
|
464
500
|
};
|
|
465
501
|
|
|
466
|
-
private get filteredSlashItems(): BlockType[] {
|
|
467
|
-
const filter = this.slashQuery.toLowerCase();
|
|
468
|
-
return BLOCK_TYPES.filter(item => item.label.toLowerCase().includes(filter));
|
|
469
|
-
}
|
|
470
|
-
|
|
471
502
|
private handleDraftInput = (e: Event) => {
|
|
472
503
|
const input = e.target as HTMLTextAreaElement;
|
|
473
504
|
this.editingDraft = input.value;
|
|
@@ -479,46 +510,14 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
479
510
|
void this.updateComplete.then(() => this.autosize(input));
|
|
480
511
|
}
|
|
481
512
|
|
|
482
|
-
// A leading "/" in an otherwise fresh block opens the slash menu; the rest
|
|
483
|
-
// of the line filters it.
|
|
484
|
-
if (input.value === '/') {
|
|
485
|
-
this.slashMenuOpen = true;
|
|
486
|
-
this.slashQuery = '';
|
|
487
|
-
this.slashActiveIndex = 0;
|
|
488
|
-
} else if (this.slashMenuOpen) {
|
|
489
|
-
if (input.value.startsWith('/') && !input.value.includes('\n')) {
|
|
490
|
-
this.slashQuery = input.value.slice(1);
|
|
491
|
-
this.slashActiveIndex = 0;
|
|
492
|
-
} else {
|
|
493
|
-
this.slashMenuOpen = false;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
513
|
this.emit('zn-input');
|
|
498
514
|
};
|
|
499
515
|
|
|
500
516
|
private handleEditKeydown = (e: KeyboardEvent) => {
|
|
501
517
|
const input = e.target as HTMLTextAreaElement;
|
|
502
518
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
506
|
-
e.preventDefault();
|
|
507
|
-
const step = e.key === 'ArrowDown' ? 1 : -1;
|
|
508
|
-
this.slashActiveIndex = (this.slashActiveIndex + step + items.length) % Math.max(items.length, 1);
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
511
|
-
if (e.key === 'Enter' && items.length) {
|
|
512
|
-
e.preventDefault();
|
|
513
|
-
this.applySlashItem(items[this.slashActiveIndex] ?? items[0]);
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
if (e.key === 'Escape') {
|
|
517
|
-
e.preventDefault();
|
|
518
|
-
this.slashMenuOpen = false;
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
519
|
+
// The slash menu claims navigation, Enter and Escape in the capture phase.
|
|
520
|
+
if (this.slashController.open) return;
|
|
522
521
|
|
|
523
522
|
if (e.key === 'Enter' && e.shiftKey) {
|
|
524
523
|
e.preventDefault();
|
|
@@ -534,27 +533,32 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
534
533
|
}
|
|
535
534
|
};
|
|
536
535
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const
|
|
540
|
-
|
|
536
|
+
/** Whether the block being edited is nothing but the slash command. */
|
|
537
|
+
private isSlashBlock(): boolean {
|
|
538
|
+
const value = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')?.value ?? '';
|
|
539
|
+
return value.startsWith('/') && !value.includes('\n');
|
|
540
|
+
}
|
|
541
541
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
this.
|
|
545
|
-
|
|
546
|
-
input?.blur();
|
|
547
|
-
this.pickImage(index);
|
|
548
|
-
return;
|
|
542
|
+
private async mountSlashMenu(): Promise<ZnSlashMenu | null> {
|
|
543
|
+
if (!this.hasSlashMenu) {
|
|
544
|
+
this.hasSlashMenu = true;
|
|
545
|
+
await this.updateComplete;
|
|
549
546
|
}
|
|
547
|
+
return this.slashMenuElement;
|
|
548
|
+
}
|
|
550
549
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
550
|
+
/** Returns false for items the controller should not insert text for. */
|
|
551
|
+
private handleSlashSelect(item: SlashMenuItem): boolean {
|
|
552
|
+
if (item.action !== 'image') return true;
|
|
553
|
+
|
|
554
|
+
const index = this.editingIndex ?? this.blocks.length;
|
|
555
|
+
// Let the controller strip the "/…" text first, then drop the empty draft
|
|
556
|
+
// block and run the image flow in its place.
|
|
557
|
+
void this.updateComplete.then(() => {
|
|
558
|
+
this.blur();
|
|
559
|
+
this.pickImage(index);
|
|
560
|
+
});
|
|
561
|
+
return false;
|
|
558
562
|
}
|
|
559
563
|
|
|
560
564
|
private handleEditPaste = (e: ClipboardEvent) => {
|
|
@@ -566,10 +570,12 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
566
570
|
};
|
|
567
571
|
|
|
568
572
|
private handleDragOver = (e: DragEvent) => {
|
|
573
|
+
if (this.rawMode) return;
|
|
569
574
|
if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
|
|
570
575
|
};
|
|
571
576
|
|
|
572
577
|
private handleDrop = (e: DragEvent) => {
|
|
578
|
+
if (this.rawMode) return;
|
|
573
579
|
const file = Array.from(e.dataTransfer?.files ?? []).find(f => f.type.startsWith('image/'));
|
|
574
580
|
if (!file) return;
|
|
575
581
|
e.preventDefault();
|
|
@@ -730,6 +736,61 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
730
736
|
input.style.height = `${input.scrollHeight}px`;
|
|
731
737
|
}
|
|
732
738
|
|
|
739
|
+
private toggleRawMode = () => {
|
|
740
|
+
if (this.rawMode) {
|
|
741
|
+
this.commitRaw();
|
|
742
|
+
this.rawMode = false;
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (this.editingIndex !== null) this.commitEdit();
|
|
746
|
+
// The per-block textarea the menu watches is about to be replaced.
|
|
747
|
+
this.slashController.detach();
|
|
748
|
+
this.rawEntryValue = this.value;
|
|
749
|
+
this.rawMode = true;
|
|
750
|
+
void this.focusRaw();
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
private async focusRaw() {
|
|
754
|
+
await this.updateComplete;
|
|
755
|
+
const raw = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__raw');
|
|
756
|
+
if (!raw) return;
|
|
757
|
+
raw.focus();
|
|
758
|
+
raw.setSelectionRange(raw.value.length, raw.value.length);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Raw mode keeps the whole document in one textarea, so the textarea — not
|
|
763
|
+
* the block list — is authoritative while it is open: re-splitting on every
|
|
764
|
+
* keystroke would normalise blank lines out from under the cursor.
|
|
765
|
+
*/
|
|
766
|
+
private handleRawInput = (e: Event) => {
|
|
767
|
+
const input = e.target as HTMLTextAreaElement;
|
|
768
|
+
if (input.value === this.value) return;
|
|
769
|
+
this.suppressValueSync = true;
|
|
770
|
+
this.value = input.value;
|
|
771
|
+
this.formControlController.updateValidity();
|
|
772
|
+
this.emit('zn-input');
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Re-splits the raw source into blocks. Not `updateBlocks` — that only
|
|
777
|
+
* reports a change when the re-join differs from the value, and raw edits
|
|
778
|
+
* have already written straight to the value.
|
|
779
|
+
*/
|
|
780
|
+
private commitRaw = () => {
|
|
781
|
+
const blocks = this.splitBlocks(this.value || '');
|
|
782
|
+
const joined = blocks.join('\n\n');
|
|
783
|
+
this.blocks = blocks;
|
|
784
|
+
if (joined !== this.value) {
|
|
785
|
+
this.suppressValueSync = true;
|
|
786
|
+
this.value = joined;
|
|
787
|
+
}
|
|
788
|
+
if (this.value === this.rawEntryValue) return;
|
|
789
|
+
this.rawEntryValue = this.value;
|
|
790
|
+
this.formControlController.updateValidity();
|
|
791
|
+
this.emit('zn-change');
|
|
792
|
+
};
|
|
793
|
+
|
|
733
794
|
private handleToolbarInsert(item: BlockType) {
|
|
734
795
|
if (this.editingIndex !== null) this.suppressBlurCommit = true;
|
|
735
796
|
const index = this.editingIndex !== null ? this.commitEdit() : this.blocks.length;
|
|
@@ -740,27 +801,6 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
740
801
|
}
|
|
741
802
|
}
|
|
742
803
|
|
|
743
|
-
private renderSlashMenu() {
|
|
744
|
-
const items = this.filteredSlashItems;
|
|
745
|
-
if (!items.length) return '';
|
|
746
|
-
return html`
|
|
747
|
-
<div part="slash-menu" class="remarkd-editor__slash-menu">
|
|
748
|
-
${items.map((item, i) => html`
|
|
749
|
-
<button type="button"
|
|
750
|
-
class=${classMap({
|
|
751
|
-
'remarkd-editor__slash-item': true,
|
|
752
|
-
'remarkd-editor__slash-item--active': i === this.slashActiveIndex,
|
|
753
|
-
})}
|
|
754
|
-
@mousedown=${(e: Event) => {
|
|
755
|
-
e.preventDefault();
|
|
756
|
-
this.applySlashItem(item);
|
|
757
|
-
}}>
|
|
758
|
-
<zn-icon src=${item.icon} size="16"></zn-icon>
|
|
759
|
-
<span>${item.label}</span>
|
|
760
|
-
</button>`)}
|
|
761
|
-
</div>`;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
804
|
private renderImageControls() {
|
|
765
805
|
const data = this.imageEdit;
|
|
766
806
|
if (!data) return '';
|
|
@@ -847,7 +887,6 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
847
887
|
@paste=${this.handleEditPaste}
|
|
848
888
|
@blur=${this.handleEditBlur}></textarea>
|
|
849
889
|
</div>
|
|
850
|
-
${this.slashMenuOpen ? this.renderSlashMenu() : ''}
|
|
851
890
|
</div>`;
|
|
852
891
|
}
|
|
853
892
|
|
|
@@ -893,17 +932,29 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
893
932
|
@drop=${this.handleDrop}>
|
|
894
933
|
${editable ? html`
|
|
895
934
|
<div part="toolbar" class="remarkd-editor__toolbar">
|
|
896
|
-
${BLOCK_TYPES.map(item => html`
|
|
935
|
+
${this.rawMode ? '' : BLOCK_TYPES.map(item => html`
|
|
897
936
|
<zn-button type="button" icon-button plain icon=${item.icon} icon-size="18"
|
|
898
937
|
tooltip=${item.label}
|
|
899
938
|
@click=${() => this.handleToolbarInsert(item)}></zn-button>`)}
|
|
939
|
+
${this.allowRaw ? html`
|
|
940
|
+
<zn-button part="raw-toggle" class="remarkd-editor__raw-toggle"
|
|
941
|
+
type="button" icon-button icon="code-xml@lu" icon-size="18"
|
|
942
|
+
?plain=${!this.rawMode}
|
|
943
|
+
tooltip=${this.rawMode ? 'Show blocks' : 'Show raw source'}
|
|
944
|
+
@click=${this.toggleRawMode}></zn-button>` : ''}
|
|
900
945
|
</div>` : ''}
|
|
901
|
-
<div class
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
946
|
+
<div class=${classMap({
|
|
947
|
+
'remarkd-editor__body': true,
|
|
948
|
+
'remarkd-editor__body--raw': this.rawMode,
|
|
949
|
+
})}>
|
|
950
|
+
${this.rawMode ? this.renderRaw() : html`
|
|
951
|
+
${this.renderBody()}
|
|
952
|
+
<div class="remarkd-editor__add" @click=${() => this.insertDraftBlock(this.blocks.length)}>
|
|
953
|
+
${this.blocks.length === 0 && this.editingIndex === null ? this.placeholder : ''}
|
|
954
|
+
</div>`}
|
|
906
955
|
</div>
|
|
956
|
+
${this.hasSlashMenu ? html`
|
|
957
|
+
<zn-slash-menu part="slash-menu" heading="Insert block"></zn-slash-menu>` : ''}
|
|
907
958
|
<textarea class="remarkd-editor__validation"
|
|
908
959
|
.value=${this.value}
|
|
909
960
|
?required=${this.required}
|
|
@@ -912,6 +963,17 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
|
|
|
912
963
|
</div>`;
|
|
913
964
|
}
|
|
914
965
|
|
|
966
|
+
private renderRaw() {
|
|
967
|
+
return html`
|
|
968
|
+
<textarea part="raw"
|
|
969
|
+
class="remarkd-editor__raw"
|
|
970
|
+
placeholder=${this.placeholder}
|
|
971
|
+
spellcheck="false"
|
|
972
|
+
.value=${this.value}
|
|
973
|
+
@input=${this.handleRawInput}
|
|
974
|
+
@blur=${this.commitRaw}></textarea>`;
|
|
975
|
+
}
|
|
976
|
+
|
|
915
977
|
/** The block views, with the inline image picker spliced in when active. */
|
|
916
978
|
private renderBody() {
|
|
917
979
|
const views = this.blocks.map((block, index) => this.renderBlock(block, index));
|
|
@@ -26,6 +26,30 @@
|
|
|
26
26
|
min-height: 10rem;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Raw mode has no gutter handles, so it reclaims the left padding.
|
|
30
|
+
.remarkd-editor__body--raw {
|
|
31
|
+
display: flex;
|
|
32
|
+
padding-left: var(--zn-spacing-large);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.remarkd-editor__raw-toggle {
|
|
36
|
+
margin-left: auto;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.remarkd-editor__raw {
|
|
40
|
+
flex: 1;
|
|
41
|
+
min-height: 10rem;
|
|
42
|
+
resize: vertical;
|
|
43
|
+
border: 0;
|
|
44
|
+
background: transparent;
|
|
45
|
+
color: inherit;
|
|
46
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
47
|
+
font-size: 0.875rem;
|
|
48
|
+
line-height: 1.5;
|
|
49
|
+
padding: 0;
|
|
50
|
+
outline: none;
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
.remarkd-editor--disabled {
|
|
30
54
|
opacity: 0.6;
|
|
31
55
|
pointer-events: none;
|
|
@@ -297,42 +321,6 @@
|
|
|
297
321
|
position: relative;
|
|
298
322
|
}
|
|
299
323
|
|
|
300
|
-
.remarkd-editor__slash-menu {
|
|
301
|
-
position: absolute;
|
|
302
|
-
top: 100%;
|
|
303
|
-
left: 0;
|
|
304
|
-
z-index: 10;
|
|
305
|
-
min-width: 12rem;
|
|
306
|
-
margin-top: 4px;
|
|
307
|
-
padding: 4px;
|
|
308
|
-
border: 1px solid rgb(var(--zn-border-color));
|
|
309
|
-
border-radius: var(--zn-border-radius);
|
|
310
|
-
background: var(--zn-color-neutral-0, white);
|
|
311
|
-
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
312
|
-
display: flex;
|
|
313
|
-
flex-direction: column;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
.remarkd-editor__slash-item {
|
|
317
|
-
display: flex;
|
|
318
|
-
align-items: center;
|
|
319
|
-
gap: var(--zn-spacing-x-small);
|
|
320
|
-
padding: 6px 8px;
|
|
321
|
-
border: 0;
|
|
322
|
-
border-radius: var(--zn-border-radius);
|
|
323
|
-
background: none;
|
|
324
|
-
color: var(--zn-color-text, #222);
|
|
325
|
-
font: inherit;
|
|
326
|
-
font-size: 0.875rem;
|
|
327
|
-
text-align: left;
|
|
328
|
-
cursor: pointer;
|
|
329
|
-
|
|
330
|
-
&:hover,
|
|
331
|
-
&--active {
|
|
332
|
-
background: var(--zn-color-neutral-100);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
324
|
.remarkd-editor__validation {
|
|
337
325
|
position: absolute;
|
|
338
326
|
width: 1px;
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import '../../../dist/zn.min.js';
|
|
2
|
-
import {expect, fixture, html} from '@open-wc/testing';
|
|
2
|
+
import {expect, fixture, html, waitUntil} from '@open-wc/testing';
|
|
3
3
|
import type ZnRemarkdEditor from './remarkd-editor.component';
|
|
4
|
+
import type ZnSlashMenu from '../slash-menu';
|
|
5
|
+
|
|
6
|
+
/** Types into the block being edited, moving the caret to the end as a keystroke would. */
|
|
7
|
+
function typeInBlock(el: ZnRemarkdEditor, value: string): HTMLTextAreaElement {
|
|
8
|
+
const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
|
|
9
|
+
input.value = value;
|
|
10
|
+
input.setSelectionRange(value.length, value.length);
|
|
11
|
+
input.dispatchEvent(new Event('input', {bubbles: true}));
|
|
12
|
+
return input;
|
|
13
|
+
}
|
|
4
14
|
|
|
5
15
|
describe('<zn-remarkd-editor>', () => {
|
|
6
16
|
it('should render a component', async () => {
|
|
@@ -69,18 +79,50 @@ Second"></zn-remarkd-editor>`);
|
|
|
69
79
|
expect(el.shadowRoot!.querySelector('.remarkd-editor__toolbar')).to.exist;
|
|
70
80
|
});
|
|
71
81
|
|
|
72
|
-
it('should open the
|
|
82
|
+
it('should open zn-slash-menu with the block types when "/" starts an empty block', async () => {
|
|
73
83
|
const el = await fixture<ZnRemarkdEditor>(html`
|
|
74
84
|
<zn-remarkd-editor value="Hello"></zn-remarkd-editor>`);
|
|
75
85
|
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
76
86
|
await el.updateComplete;
|
|
77
87
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
typeInBlock(el, '/');
|
|
89
|
+
await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
|
|
90
|
+
|
|
91
|
+
const menu = el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu')!;
|
|
92
|
+
expect(menu.items.map(item => item.label)).to.contain('Heading 1');
|
|
93
|
+
expect(menu.activeItem?.label).to.equal('Text');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should not open the slash menu part-way through a block', async () => {
|
|
97
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
98
|
+
<zn-remarkd-editor value="Hello"></zn-remarkd-editor>`);
|
|
99
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
100
|
+
await el.updateComplete;
|
|
101
|
+
|
|
102
|
+
typeInBlock(el, 'Hello /');
|
|
103
|
+
await el.updateComplete;
|
|
104
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
105
|
+
|
|
106
|
+
expect(el.shadowRoot!.querySelector('zn-slash-menu[open]')).to.not.exist;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should insert the chosen block prefix over the slash command', async () => {
|
|
110
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
111
|
+
<zn-remarkd-editor value="Hello"></zn-remarkd-editor>`);
|
|
112
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
81
113
|
await el.updateComplete;
|
|
82
114
|
|
|
83
|
-
|
|
115
|
+
const input = typeInBlock(el, '/note');
|
|
116
|
+
await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
|
|
117
|
+
|
|
118
|
+
const menu = el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu')!;
|
|
119
|
+
expect(menu.activeItem?.label).to.equal('Note');
|
|
120
|
+
|
|
121
|
+
input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
|
|
122
|
+
await el.updateComplete;
|
|
123
|
+
|
|
124
|
+
expect(input.value).to.equal('NOTE: ');
|
|
125
|
+
expect(el.shadowRoot!.querySelector('zn-slash-menu[open]')).to.not.exist;
|
|
84
126
|
});
|
|
85
127
|
|
|
86
128
|
it('should start a new block on shift+enter', async () => {
|
|
@@ -100,6 +142,24 @@ Second"></zn-remarkd-editor>`);
|
|
|
100
142
|
expect(draft.value).to.equal('');
|
|
101
143
|
});
|
|
102
144
|
|
|
145
|
+
it('should swap the slash command for the image picker when Image is chosen', async () => {
|
|
146
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
147
|
+
<zn-remarkd-editor attachment-url="/upload" value="Hello"></zn-remarkd-editor>`);
|
|
148
|
+
el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
|
|
149
|
+
await el.updateComplete;
|
|
150
|
+
|
|
151
|
+
const input = typeInBlock(el, '/image');
|
|
152
|
+
await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened');
|
|
153
|
+
|
|
154
|
+
input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
|
|
155
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__image-picker'),
|
|
156
|
+
'the image picker never opened');
|
|
157
|
+
|
|
158
|
+
// The "/image" draft is dropped rather than committed as a block.
|
|
159
|
+
expect(el.value).to.equal('');
|
|
160
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__input')).to.not.exist;
|
|
161
|
+
});
|
|
162
|
+
|
|
103
163
|
it('should show an inline zn-file picker from the toolbar image button', async () => {
|
|
104
164
|
const el = await fixture<ZnRemarkdEditor>(html`
|
|
105
165
|
<zn-remarkd-editor attachment-url="/upload"></zn-remarkd-editor>`);
|
|
@@ -160,6 +220,50 @@ Second"></zn-remarkd-editor>`);
|
|
|
160
220
|
expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1);
|
|
161
221
|
});
|
|
162
222
|
|
|
223
|
+
it('should only show the raw toggle when allow-raw is set', async () => {
|
|
224
|
+
const plain = await fixture<ZnRemarkdEditor>(html`
|
|
225
|
+
<zn-remarkd-editor value="Hello"></zn-remarkd-editor>`);
|
|
226
|
+
expect(plain.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')).to.not.exist;
|
|
227
|
+
|
|
228
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
229
|
+
<zn-remarkd-editor allow-raw value="Hello"></zn-remarkd-editor>`);
|
|
230
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')).to.exist;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should swap the blocks for a full-source textarea and commit edits on toggle back', async () => {
|
|
234
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
235
|
+
<zn-remarkd-editor allow-raw value="# Title
|
|
236
|
+
|
|
237
|
+
Second"></zn-remarkd-editor>`);
|
|
238
|
+
let changes = 0;
|
|
239
|
+
el.addEventListener('zn-change', () => {
|
|
240
|
+
changes++;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const toggle = el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')!;
|
|
244
|
+
toggle.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
245
|
+
await el.updateComplete;
|
|
246
|
+
|
|
247
|
+
const raw = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__raw')!;
|
|
248
|
+
expect(raw).to.exist;
|
|
249
|
+
expect(raw.value).to.equal('# Title\n\nSecond');
|
|
250
|
+
expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(0);
|
|
251
|
+
|
|
252
|
+
raw.value = '# Changed\n\nSecond\n\nThird';
|
|
253
|
+
raw.dispatchEvent(new Event('input', {bubbles: true}));
|
|
254
|
+
await el.updateComplete;
|
|
255
|
+
expect(el.value).to.equal('# Changed\n\nSecond\n\nThird');
|
|
256
|
+
|
|
257
|
+
el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')!
|
|
258
|
+
.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
259
|
+
await el.updateComplete;
|
|
260
|
+
|
|
261
|
+
const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block');
|
|
262
|
+
expect(blocks.length).to.equal(3);
|
|
263
|
+
expect(blocks[0].innerHTML).to.contain('Changed');
|
|
264
|
+
expect(changes).to.be.greaterThan(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
163
267
|
it('should emit zn-change when a block edit changes the value', async () => {
|
|
164
268
|
const el = await fixture<ZnRemarkdEditor>(html`
|
|
165
269
|
<zn-remarkd-editor value="Hello"></zn-remarkd-editor>`);
|