@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.
@@ -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 slash-menu - The context menu opened by typing "/" in a block.
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
- @state() private slashMenuOpen = false;
87
- @state() private slashQuery = '';
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.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')?.blur();
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.slashMenuOpen = false;
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.slashMenuOpen = false;
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.slashMenuOpen = false;
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
- if (this.slashMenuOpen) {
504
- const items = this.filteredSlashItems;
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
- private applySlashItem(item: BlockType) {
538
- this.slashMenuOpen = false;
539
- const index = this.editingIndex ?? this.blocks.length;
540
- const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
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
- if (item.image) {
543
- // Drop the "/..." draft block, then run the image flow in its place.
544
- this.editingDraft = '';
545
- if (input) input.value = '';
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
- this.editingDraft = item.prefix ?? '';
552
- if (input) {
553
- input.value = this.editingDraft;
554
- this.autosize(input);
555
- input.focus();
556
- input.setSelectionRange(input.value.length, input.value.length);
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="remarkd-editor__body">
902
- ${this.renderBody()}
903
- <div class="remarkd-editor__add" @click=${() => this.insertDraftBlock(this.blocks.length)}>
904
- ${this.blocks.length === 0 && this.editingIndex === null ? this.placeholder : ''}
905
- </div>
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 slash menu when "/" starts an empty block', async () => {
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
- const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
79
- input.value = '/';
80
- input.dispatchEvent(new Event('input', {bubbles: true}));
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
- expect(el.shadowRoot!.querySelector('.remarkd-editor__slash-menu')).to.exist;
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>`);