@kubex/zinc 1.1.163 → 1.1.164

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.163",
3
+ "version": "1.1.164",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -17,9 +17,27 @@
17
17
  // Same surface as the shell-header menus below, so every dropdown in the
18
18
  // product reads as one material rather than splitting into --zn-panel menus
19
19
  // and --zn-color-navigation ones depending on where it was mounted.
20
- // (No backdrop-filter here: at 0.95 the blur is not worth the GPU cost on
21
- // every dropdown, and .menu--shell keeps it for the chrome look.)
22
- background-color: rgba(var(--zn-color-navigation, 255, 255, 255), 0.95);
20
+ background-color: rgb(var(--zn-color-navigation, 255, 255, 255));
21
+ }
22
+
23
+ // A submenu's border sits on its parent menu's edge border, so the two share one line.
24
+ :host([slot="submenu"]) .menu {
25
+ border-start-start-radius: 0;
26
+ border-end-start-radius: 0;
27
+ }
28
+
29
+ // Set by the submenu controller while this submenu extends past its parent's bottom edge.
30
+ :host([slot="submenu"][data-extends-below]) .menu {
31
+ border-end-start-radius: var(--zn-border-radius);
32
+ }
33
+
34
+ // Set by the submenu controller on the parent menu while a submenu covers that corner.
35
+ :host([data-submenu-top]) .menu {
36
+ border-start-end-radius: 0;
37
+ }
38
+
39
+ :host([data-submenu-bottom]) .menu {
40
+ border-end-end-radius: 0;
23
41
  }
24
42
 
25
43
  // A capped menu has to look scrollable, so the bar is always drawn rather than
@@ -260,7 +260,7 @@ export default class ZnMenuItem extends ZincElement {
260
260
  </span>` : ''}
261
261
 
262
262
  <span part="submenu-icon" class="menu-item__chevron">
263
- <zn-icon src=${isRtl ? 'chevron_left' : 'chevron_right'} aria-hidden="true"></zn-icon>
263
+ <zn-icon src=${isRtl ? 'chevron_left' : 'chevron_right'} size="18" aria-hidden="true"></zn-icon>
264
264
  </span>
265
265
 
266
266
  ${this.submenuController.renderSubmenu()}
@@ -1,8 +1,9 @@
1
1
  @use "../../wc";
2
2
 
3
3
  :host {
4
+ // Overlaps the parent menu's padding so the submenu's border lands on the parent's.
5
+ --submenu-offset: 1px;
4
6
  // Baseline — standalone items (e.g. app/sidebar navigation): primary hover
5
- --submenu-offset: -2px;
6
7
  --color: var(--zn-text-menu-link-color);
7
8
  --hover-color: rgb(var(--zn-primary));
8
9
  --hover-bg: rgba(var(--zn-color-navigation-highlight), var(--zn-panel-highlight-opacity));
@@ -239,6 +240,8 @@
239
240
 
240
241
  .menu-item--has-submenu .menu-item__chevron {
241
242
  display: flex;
243
+ height: auto;
244
+ margin-inline-start: var(--zn-spacing-x-small);
242
245
  }
243
246
 
244
247
  :host(:not([role="menuitemcheckbox"])) .menu-item .menu-item__check {
@@ -251,9 +254,7 @@
251
254
  display: flex;
252
255
  }
253
256
 
254
- /* Add elevation and z-index to submenus */
255
257
  zn-popup::part(popup) {
256
- box-shadow: var(--zn-shadow-large);
257
258
  z-index: 100;
258
259
  margin-left: var(--submenu-offset);
259
260
  }
@@ -210,6 +210,16 @@ export class SubmenuController implements ReactiveController {
210
210
 
211
211
  const {left, top, width, height} = menu.getBoundingClientRect();
212
212
 
213
+ const parentMenu = this.host.parentElement;
214
+ const parentPanel = parentMenu?.shadowRoot?.querySelector('.menu') ?? parentMenu;
215
+ if (parentMenu && parentPanel) {
216
+ const parent = parentPanel.getBoundingClientRect();
217
+ menu.toggleAttribute('data-extends-below', top + height > parent.bottom + 1);
218
+ // Flags the parent's corners the submenu covers, so the parent squares them off.
219
+ parentMenu.toggleAttribute('data-submenu-top', top <= parent.top + 1);
220
+ parentMenu.toggleAttribute('data-submenu-bottom', top + height >= parent.bottom - 1);
221
+ }
222
+
213
223
  this.host.style.setProperty('--safe-triangle-submenu-start-x', `${isRtl ? left + width : left}px`);
214
224
  this.host.style.setProperty('--safe-triangle-submenu-start-y', `${top}px`);
215
225
  this.host.style.setProperty('--safe-triangle-submenu-end-x', `${isRtl ? left + width : left}px`);
@@ -223,6 +233,10 @@ export class SubmenuController implements ReactiveController {
223
233
  this.host.requestUpdate();
224
234
  }
225
235
  }
236
+ if (!state) {
237
+ this.host.parentElement?.removeAttribute('data-submenu-top');
238
+ this.host.parentElement?.removeAttribute('data-submenu-bottom');
239
+ }
226
240
  }
227
241
 
228
242
  // Shows the submenu. Supports disabling the opening delay, e.g. for keyboard events that want to set the focus to the
@@ -244,21 +258,12 @@ export class SubmenuController implements ReactiveController {
244
258
 
245
259
  // Calculate the space the top of a menu takes-up, for aligning the popup menu-item with the activating element.
246
260
  private updateSkidding(): void {
247
- // .computedStyleMap() not always available.
248
- if (!this.host.parentElement?.computedStyleMap) {
249
- return;
250
- }
251
- const styleMap: StylePropertyMapReadOnly = this.host.parentElement.computedStyleMap();
252
- const attrs: string[] = ['padding-top', 'border-top-width', 'margin-top'];
253
-
254
- const skidding = attrs.reduce((accumulator, attr) => {
255
- const styleValue: CSSStyleValue = styleMap.get(attr) ?? new CSSUnitValue(0, 'px');
256
- const unitValue = styleValue instanceof CSSUnitValue ? styleValue : new CSSUnitValue(0, 'px');
257
- const pxValue = unitValue.to('px');
258
- return accumulator - pxValue.value;
259
- }, 0);
260
-
261
- this.skidding = skidding;
261
+ // zn-menu's padding and border sit on its inner `.menu`, not the host.
262
+ const menu = this.host.parentElement;
263
+ const panel = menu?.shadowRoot?.querySelector('.menu') ?? menu;
264
+ if (!panel) return;
265
+ const style = getComputedStyle(panel);
266
+ this.skidding = -(parseFloat(style.paddingTop) + parseFloat(style.borderTopWidth) + parseFloat(style.marginTop));
262
267
  }
263
268
 
264
269
  isExpanded(): boolean {
@@ -31,7 +31,7 @@ function isVirtualElement(e: unknown): e is VirtualElement {
31
31
  *
32
32
  * @dependency zn-example
33
33
  *
34
- * @event zn-event-name - Emitted as an example.
34
+ * @event zn-reposition - Emitted each time the popup is positioned.
35
35
  *
36
36
  * @slot - The default slot.
37
37
  * @slot example - An example slot.
@@ -418,6 +418,8 @@ export default class ZnPopup extends ZincElement {
418
418
  this.popup.showPopover();
419
419
  }
420
420
 
421
+ this.emit('zn-reposition');
422
+
421
423
  if (this.arrow) {
422
424
  const arrowX = middlewareData.arrow!.x;
423
425
  const arrowY = middlewareData.arrow!.y;
@@ -10,6 +10,12 @@ export interface InlineMark {
10
10
  placeholder?: string;
11
11
  }
12
12
 
13
+ /** A shortcut on the platform modifier: Cmd on Apple devices, Ctrl elsewhere. */
14
+ export interface Shortcut {
15
+ key: string;
16
+ shift?: boolean;
17
+ }
18
+
13
19
  export interface EditorAction {
14
20
  /** Stable id, used for toolbar keys and tests. */
15
21
  key: string;
@@ -26,6 +32,7 @@ export interface EditorAction {
26
32
  caretOffset?: number;
27
33
  /** Actions that open their own picker instead of inserting text. */
28
34
  opens?: 'image' | 'include' | 'link';
35
+ shortcut?: Shortcut;
29
36
  }
30
37
 
31
38
  /** Toolbar order, most-used first — the last groups are the first to collapse. */
@@ -116,23 +123,43 @@ export const EDITOR_ACTIONS: EditorAction[] = [
116
123
  {key: 'ifeval', label: 'If expression', icon: 'equal@lu', group: 'logic', prefix: 'ifeval::[1 > 0]\n\nendif::[]', caretOffset: 9},
117
124
 
118
125
  // Inline — applied to the selection in the open block
119
- {key: 'inline-formatting', label: 'Strong', icon: 'bold@lu', group: 'inline', keywords: ['bold'], inline: {before: '**', placeholder: 'text'}},
120
- {key: 'emphasis', label: 'Emphasis', icon: 'italic@lu', group: 'inline', keywords: ['italic'], inline: {before: '__', placeholder: 'text'}},
121
- {key: 'inline-code', label: 'Code', icon: 'square-code@lu', group: 'inline', inline: {before: '`', placeholder: 'code'}},
122
- {key: 'underline', label: 'Underline', icon: 'underline@lu', group: 'inline', inline: {before: '___', placeholder: 'text'}},
123
- {key: 'strike', label: 'Strikethrough', icon: 'strikethrough@lu', group: 'inline', keywords: ['delete'], inline: {before: '~~', placeholder: 'text'}},
124
- {key: 'subscript', label: 'Subscript', icon: 'subscript@lu', group: 'inline', inline: {before: '~', placeholder: '2'}},
125
- {key: 'superscript', label: 'Superscript', icon: 'superscript@lu', group: 'inline', inline: {before: '^', placeholder: '2'}},
126
+ {key: 'inline-formatting', label: 'Strong', icon: 'bold@lu', group: 'inline', keywords: ['bold'], inline: {before: '**', placeholder: 'text'}, shortcut: {key: 'b'}},
127
+ {key: 'emphasis', label: 'Emphasis', icon: 'italic@lu', group: 'inline', keywords: ['italic'], inline: {before: '__', placeholder: 'text'}, shortcut: {key: 'i'}},
128
+ {key: 'inline-code', label: 'Code', icon: 'square-code@lu', group: 'inline', inline: {before: '`', placeholder: 'code'}, shortcut: {key: 'e'}},
129
+ {key: 'underline', label: 'Underline', icon: 'underline@lu', group: 'inline', inline: {before: '___', placeholder: 'text'}, shortcut: {key: 'u'}},
130
+ {key: 'strike', label: 'Strikethrough', icon: 'strikethrough@lu', group: 'inline', keywords: ['delete'], inline: {before: '~~', placeholder: 'text'}, shortcut: {key: 'x', shift: true}},
131
+ {key: 'subscript', label: 'Subscript', icon: 'subscript@lu', group: 'inline', inline: {before: '~', placeholder: '2'}, shortcut: {key: ','}},
132
+ {key: 'superscript', label: 'Superscript', icon: 'superscript@lu', group: 'inline', inline: {before: '^', placeholder: '2'}, shortcut: {key: '.'}},
126
133
  {key: 'keyboard', label: 'Keyboard', icon: 'keyboard@lu', group: 'inline', keywords: ['kbd', 'shortcut'], inline: {before: 'kbd:[', after: ']', placeholder: 'Ctrl+C'}},
127
134
  {key: 'footnote', label: 'Footnote', icon: 'asterisk@lu', group: 'inline', inline: {before: 'footnote:[', after: ']', placeholder: 'Note'}},
128
135
  {key: 'tooltip', label: 'Tooltip', icon: 'message-circle-question-mark@lu', group: 'inline', keywords: ['term'], inline: {before: '{', after: '}(Explanation)', placeholder: 'Term'}},
129
136
  {key: 'cross-reference', label: 'Cross reference', icon: 'link-2@lu', group: 'inline', keywords: ['xref'], inline: {before: '<<', after: '>>', placeholder: 'section,Label'}},
130
- {key: 'links-and-images', label: 'Link', icon: 'link@lu', group: 'inline', inline: {before: '[', after: '](https://)', placeholder: 'Label'}},
131
- {key: 'document-link', label: 'Link to article', icon: 'file-symlink@lu', group: 'inline', keywords: ['article', 'document', 'kb'], opens: 'link'},
137
+ {key: 'links-and-images', label: 'Link', icon: 'link@lu', group: 'inline', inline: {before: '[', after: '](https://)', placeholder: 'Label'}, shortcut: {key: 'k'}},
138
+ {key: 'document-link', label: 'Link to article', icon: 'file-symlink@lu', group: 'inline', keywords: ['article', 'document', 'kb'], opens: 'link', shortcut: {key: 'k', shift: true}},
132
139
  {key: 'passthrough', label: 'Passthrough', icon: 'shield@lu', group: 'inline', keywords: ['raw', 'literal'], inline: {before: 'pass:[', after: ']', placeholder: 'raw'}},
133
140
  {key: 'curly-bang-passthrough', label: 'Literal braces', icon: 'braces@lu', group: 'inline', inline: {before: '{!', after: '!}', placeholder: 'raw'}},
134
141
  ];
135
142
 
143
+ export function isApple(): boolean {
144
+ // Safari and Firefox have no userAgentData yet.
145
+ const platform = (navigator as Navigator & { userAgentData?: { platform: string } }).userAgentData?.platform
146
+ ?? navigator.userAgent;
147
+ return /mac|iphone|ipad|ipod/i.test(platform);
148
+ }
149
+
150
+ export function matchesShortcut(e: KeyboardEvent, shortcut: Shortcut): boolean {
151
+ // Ctrl+Alt is AltGr on Windows keyboards, so Alt must be up for the combo to count.
152
+ const mod = isApple() ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey;
153
+ return mod && !e.altKey && e.shiftKey === !!shortcut.shift && e.key.toLowerCase() === shortcut.key;
154
+ }
155
+
156
+ /** The shortcut as shown to people, e.g. `⌘⇧X` or `Ctrl+Shift+X`. */
157
+ export function shortcutLabel(shortcut: Shortcut): string {
158
+ const key = shortcut.key.toUpperCase();
159
+ if (isApple()) return `⌘${shortcut.shift ? '⇧' : ''}${key}`;
160
+ return `Ctrl+${shortcut.shift ? 'Shift+' : ''}${key}`;
161
+ }
162
+
136
163
  /** The registry as slash menu entries. Picker actions insert nothing; the menu emits their action id. */
137
164
  export function slashItems(actions: EditorAction[]): SlashMenuItem[] {
138
165
  const label = (id: ActionGroup) => ACTION_GROUPS.find(group => group.id === id)?.label;
@@ -0,0 +1,67 @@
1
+ import {continueList} from './list-continuation';
2
+ import {expect} from '@open-wc/testing';
3
+
4
+ /** Enter at `caret` (the end by default), rendered as the new value with `|` at the caret, or null. */
5
+ function enter(value: string, caret = value.length): string | null {
6
+ const edit = continueList(value, caret, caret);
7
+ return edit ? `${edit.value.slice(0, edit.caret)}|${edit.value.slice(edit.caret)}` : null;
8
+ }
9
+
10
+ describe('continueList', () => {
11
+ it('should continue bullet lists with the same marker, including nested depth', () => {
12
+ expect(enter('- a')).to.equal('- a\n- |');
13
+ expect(enter('* a')).to.equal('* a\n* |');
14
+ expect(enter('-- nested')).to.equal('-- nested\n-- |');
15
+ });
16
+
17
+ it('should continue numbered lists with the next number', () => {
18
+ expect(enter('1. hello')).to.equal('1. hello\n2. |');
19
+ expect(enter('9. nine')).to.equal('9. nine\n10. |');
20
+ });
21
+
22
+ it('should renumber the items after one inserted mid-list', () => {
23
+ expect(enter('1. a\n2. b\n3. c', 4)).to.equal('1. a\n2. |\n3. b\n4. c');
24
+ });
25
+
26
+ it('should continue checklists unticked, keeping the editable kind', () => {
27
+ expect(enter('- [ ] a')).to.equal('- [ ] a\n- [ ] |');
28
+ expect(enter('- [x] done')).to.equal('- [x] done\n- [ ] |');
29
+ expect(enter('- [_] editable')).to.equal('- [_] editable\n- [_] |');
30
+ expect(enter('- [*] editable done')).to.equal('- [*] editable done\n- [_] |');
31
+ });
32
+
33
+ it('should continue definition lists with the caret on the new term', () => {
34
+ expect(enter('CPU:: Central processor')).to.equal('CPU:: Central processor\n|:: ');
35
+ });
36
+
37
+ it('should end the list when Enter is pressed on an empty item', () => {
38
+ expect(enter('- a\n- ')).to.equal('- a\n|');
39
+ expect(enter('1. a\n2. ')).to.equal('1. a\n|');
40
+ expect(enter('- [ ] a\n- [ ] ')).to.equal('- [ ] a\n|');
41
+ expect(enter('CPU:: x\n:: ')).to.equal('CPU:: x\n|');
42
+ });
43
+
44
+ it('should carry the text after the caret into the new item', () => {
45
+ expect(enter('- hello world', 7)).to.equal('- hello\n- |world');
46
+ });
47
+
48
+ it('should leave Enter alone outside list items', () => {
49
+ expect(enter('plain text')).to.be.null;
50
+ expect(enter('---')).to.be.null;
51
+ expect(enter('NOTE: hi')).to.be.null;
52
+ expect(enter('include::file[]')).to.be.null;
53
+ expect(enter('ifdef::flag[]')).to.be.null;
54
+ // Caret before the marker ends, e.g. at the start of the line.
55
+ expect(enter('- a', 0)).to.be.null;
56
+ });
57
+
58
+ it('should leave Enter alone inside verbatim blocks', () => {
59
+ expect(enter('```\n- a')).to.be.null;
60
+ expect(enter('----\n1. a')).to.be.null;
61
+ expect(enter('```\ncode\n```\n- a')).to.equal('```\ncode\n```\n- a\n- |');
62
+ });
63
+
64
+ it('should leave Enter alone with a selection', () => {
65
+ expect(continueList('- abc', 2, 4)).to.be.null;
66
+ });
67
+ });
@@ -0,0 +1,73 @@
1
+ /** A textarea edit: the new value and where the caret lands in it. */
2
+ export interface TextEdit {
3
+ value: string;
4
+ caret: number;
5
+ }
6
+
7
+ // Nesting depth is the marker's repeat count (`--`, `**`), never indentation.
8
+ const LIST_ITEM = /^(-+|\*+|(\d+)\.) (\[[ xX_*]\] )?/;
9
+ const ORDERED_ITEM = /^(\d+)\. /;
10
+ // `::` must end the line or be followed by a space, so macros (`include::x[]`) never match.
11
+ const DEFINITION = /^[^:\s][^\n]*?::(?: |$)/;
12
+
13
+ // Code, listing and literal blocks — their content is verbatim, so a `- ` there is not a list.
14
+ const VERBATIM_DELIMITER = /^(`{3,}|-{4,}|\.{4,})/;
15
+
16
+ function insideVerbatim(before: string): boolean {
17
+ return before.split('\n').filter(line => VERBATIM_DELIMITER.test(line)).length % 2 === 1;
18
+ }
19
+
20
+ /** A new checklist item starts unticked, keeping the editable/readonly kind of the one above. */
21
+ function nextCheckbox(box: string): string {
22
+ return box === '[_] ' || box === '[*] ' ? '[_] ' : '[ ] ';
23
+ }
24
+
25
+ /**
26
+ * What Enter does on a list line: continues the list with the next marker, or ends it by
27
+ * clearing the marker of an item that has no text. Null when Enter should just insert a
28
+ * newline — no list line, a selection, or the caret before the marker.
29
+ */
30
+ export function continueList(value: string, start: number, end: number): TextEdit | null {
31
+ if (start !== end) return null;
32
+
33
+ const lineStart = value.lastIndexOf('\n', start - 1) + 1;
34
+ const newline = value.indexOf('\n', start);
35
+ const lineEnd = newline === -1 ? value.length : newline;
36
+ const line = value.slice(lineStart, lineEnd);
37
+
38
+ if (insideVerbatim(value.slice(0, lineStart))) return null;
39
+ const clearLine = {value: value.slice(0, lineStart) + value.slice(lineEnd), caret: lineStart};
40
+ // The bare `:: ` a definition continuation leaves behind.
41
+ if (line.trim() === '::') return clearLine;
42
+
43
+ const item = LIST_ITEM.exec(line);
44
+ const definition = item ? null : DEFINITION.exec(line);
45
+ const marker = item?.[0] ?? definition?.[0];
46
+ if (!marker || start - lineStart < marker.length) return null;
47
+ if (item && line.slice(marker.length).trim() === '') return clearLine;
48
+
49
+ if (definition) {
50
+ // The caret goes before `::` — the term is typed first.
51
+ return {value: `${value.slice(0, start)}\n:: ${value.slice(start).replace(/^ +/, '')}`, caret: start + 1};
52
+ }
53
+
54
+ const [, bullet, ordinal, box] = item!;
55
+ const next = (ordinal ? `${Number(ordinal) + 1}.` : bullet) + ' ' + (box ? nextCheckbox(box) : '');
56
+ const head = `${value.slice(0, start)}\n${next}`;
57
+ const rest = value.slice(start).replace(/^ +/, '');
58
+ const tail = ordinal ? renumber(rest, Number(ordinal) + 2) : rest;
59
+ return {value: head + tail, caret: head.length};
60
+ }
61
+
62
+ /**
63
+ * Shifts the ordered items that directly follow the split line up by one, so the source keeps
64
+ * counting in step. `text` starts with the rest of the split line, which is left alone.
65
+ */
66
+ function renumber(text: string, from: number): string {
67
+ const lines = text.split('\n');
68
+ for (let i = 1, n = from; i < lines.length; i++, n++) {
69
+ if (!ORDERED_ITEM.test(lines[i])) break;
70
+ lines[i] = lines[i].replace(ORDERED_ITEM, `${n}. `);
71
+ }
72
+ return lines.join('\n');
73
+ }
@@ -1,5 +1,6 @@
1
- import {ACTION_GROUPS, EDITOR_ACTIONS, slashItems} from './actions';
1
+ import {ACTION_GROUPS, EDITOR_ACTIONS, matchesShortcut, shortcutLabel, slashItems} from './actions';
2
2
  import {classMap} from "lit/directives/class-map.js";
3
+ import {continueList} from './list-continuation';
3
4
  import {type CSSResultGroup, html, type PropertyValues, type TemplateResult, unsafeCSS} from 'lit';
4
5
  import {defaultValue} from "../../internal/default-value";
5
6
  import {FormControlController} from "../../internal/form";
@@ -954,7 +955,17 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
954
955
  // The slash menu claims navigation, Enter and Escape in the capture phase.
955
956
  if (this.slashController.open) return;
956
957
 
957
- if (e.key === 'Enter' && e.shiftKey) {
958
+ const shortcutAction = EDITOR_ACTIONS.find(action =>
959
+ action.shortcut && matchesShortcut(e, action.shortcut) && this.actionAvailable(action));
960
+ if (shortcutAction) {
961
+ e.preventDefault();
962
+ this.activateAction(shortcutAction);
963
+ } else if (this.isPlainEnter(e)) {
964
+ const edit = continueList(input.value, input.selectionStart, input.selectionEnd);
965
+ if (!edit) return;
966
+ e.preventDefault();
967
+ this.applyDraftEdit(input, edit.value, edit.caret, edit.caret);
968
+ } else if (e.key === 'Enter' && e.shiftKey) {
958
969
  e.preventDefault();
959
970
  this.suppressBlurCommit = true;
960
971
  const next = this.commitEdit();
@@ -968,6 +979,21 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
968
979
  }
969
980
  };
970
981
 
982
+ private isPlainEnter(e: KeyboardEvent): boolean {
983
+ return e.key === 'Enter' && !e.isComposing && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey;
984
+ }
985
+
986
+ private handleRawKeydown = (e: KeyboardEvent) => {
987
+ if (!this.isPlainEnter(e)) return;
988
+ const input = e.target as HTMLTextAreaElement;
989
+ const edit = continueList(input.value, input.selectionStart, input.selectionEnd);
990
+ if (!edit) return;
991
+ e.preventDefault();
992
+ input.value = edit.value;
993
+ input.setSelectionRange(edit.caret, edit.caret);
994
+ this.handleRawInput(e);
995
+ };
996
+
971
997
  /** Whether the block being edited is nothing but the slash command. */
972
998
  private isSlashBlock(): boolean {
973
999
  const value = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')?.value ?? '';
@@ -1404,7 +1430,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1404
1430
  /**
1405
1431
  * Applies an inline mark to the open block's textarea: wraps the selection, toggles the
1406
1432
  * mark off when it is already wrapped, or inserts a selected placeholder when there is
1407
- * no selection. Goes through `editingDraft` so `zn-input` still fires.
1433
+ * no selection.
1408
1434
  */
1409
1435
  private applyInline(mark: InlineMark) {
1410
1436
  const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
@@ -1451,10 +1477,15 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1451
1477
  caret = [start + mark.before.length, start + mark.before.length + body.length];
1452
1478
  }
1453
1479
 
1454
- this.editingDraft = next;
1455
- input.value = next;
1456
- input.setSelectionRange(caret[0], caret[1]);
1457
- this.editShell = this.computeEditShell(next);
1480
+ this.applyDraftEdit(input, next, caret[0], caret[1]);
1481
+ }
1482
+
1483
+ /** Writes a programmatic edit to the open block's textarea. Goes through `editingDraft` so `zn-input` still fires. */
1484
+ private applyDraftEdit(input: HTMLTextAreaElement, value: string, selectionStart: number, selectionEnd: number) {
1485
+ this.editingDraft = value;
1486
+ input.value = value;
1487
+ input.setSelectionRange(selectionStart, selectionEnd);
1488
+ this.editShell = this.computeEditShell(value);
1458
1489
  this.autosize(input);
1459
1490
  input.focus({preventScroll: true});
1460
1491
  this.emit('zn-input');
@@ -1824,7 +1855,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1824
1855
  plain
1825
1856
  icon=${action.icon}
1826
1857
  icon-size="18"
1827
- tooltip=${action.label}
1858
+ label=${action.label}
1859
+ tooltip=${action.shortcut ? `${action.label} (${shortcutLabel(action.shortcut)})` : action.label}
1828
1860
  ?disabled=${this.isActionDisabled(action)}
1829
1861
  @mousedown=${(e: MouseEvent) => e.preventDefault()}
1830
1862
  @click=${() => this.activateAction(action)}></zn-button>`;
@@ -1838,6 +1870,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1838
1870
  @click=${() => this.activateAction(action)}>
1839
1871
  <zn-icon slot="prefix" src=${action.icon} size="18"></zn-icon>
1840
1872
  ${action.label}
1873
+ ${action.shortcut ? html`
1874
+ <span slot="suffix" class="remarkd-editor__shortcut">${shortcutLabel(action.shortcut)}</span>` : ''}
1841
1875
  </zn-menu-item>`;
1842
1876
  }
1843
1877
 
@@ -1892,7 +1926,14 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1892
1926
  tooltip="More"
1893
1927
  @mousedown=${(e: MouseEvent) => e.preventDefault()}></zn-button>
1894
1928
  <zn-menu>
1895
- ${overflowed.flatMap(entry => entry.actions).map(action => this.renderMenuAction(action))}
1929
+ ${overflowed.map(entry => html`
1930
+ <zn-menu-item>
1931
+ <zn-icon slot="prefix" src=${entry.actions[0].icon} size="18"></zn-icon>
1932
+ ${entry.group.label}
1933
+ <zn-menu slot="submenu">
1934
+ ${entry.actions.map(action => this.renderMenuAction(action))}
1935
+ </zn-menu>
1936
+ </zn-menu-item>`)}
1896
1937
  </zn-menu>
1897
1938
  </zn-dropdown>` : ''}`;
1898
1939
  })()}
@@ -1937,6 +1978,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1937
1978
  spellcheck="false"
1938
1979
  .value=${this.value}
1939
1980
  @input=${this.handleRawInput}
1981
+ @keydown=${this.handleRawKeydown}
1940
1982
  @blur=${this.commitRaw}></textarea>`;
1941
1983
  }
1942
1984
 
@@ -534,3 +534,8 @@
534
534
  text-decoration-style: wavy;
535
535
  text-decoration-color: rgb(var(--zn-color-error));
536
536
  }
537
+
538
+ .remarkd-editor__shortcut {
539
+ color: rgb(var(--zn-text-muted));
540
+ font-size: var(--zn-font-size-x-small);
541
+ }