@kubex/zinc 1.1.163 → 1.1.165

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.165",
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,79 @@
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
+ /** Whether `line` is a list marker with nothing after it — `- `, `2. `, `- [ ] `, or a bare `::`. */
26
+ export function isEmptyListItem(line: string): boolean {
27
+ if (line.trim() === '::') return true;
28
+ const item = LIST_ITEM.exec(line);
29
+ return !!item && line.slice(item[0].length).trim() === '';
30
+ }
31
+
32
+ /**
33
+ * What Enter does on a list line: continues the list with the next marker, or ends it by
34
+ * clearing the marker of an item that has no text. Null when Enter should just insert a
35
+ * newline — no list line, a selection, or the caret before the marker.
36
+ */
37
+ export function continueList(value: string, start: number, end: number): TextEdit | null {
38
+ if (start !== end) return null;
39
+
40
+ const lineStart = value.lastIndexOf('\n', start - 1) + 1;
41
+ const newline = value.indexOf('\n', start);
42
+ const lineEnd = newline === -1 ? value.length : newline;
43
+ const line = value.slice(lineStart, lineEnd);
44
+
45
+ if (insideVerbatim(value.slice(0, lineStart))) return null;
46
+ if (isEmptyListItem(line)) {
47
+ return {value: value.slice(0, lineStart) + value.slice(lineEnd), caret: lineStart};
48
+ }
49
+
50
+ const item = LIST_ITEM.exec(line);
51
+ const definition = item ? null : DEFINITION.exec(line);
52
+ const marker = item?.[0] ?? definition?.[0];
53
+ if (!marker || start - lineStart < marker.length) return null;
54
+
55
+ if (definition) {
56
+ // The caret goes before `::` — the term is typed first.
57
+ return {value: `${value.slice(0, start)}\n:: ${value.slice(start).replace(/^ +/, '')}`, caret: start + 1};
58
+ }
59
+
60
+ const [, bullet, ordinal, box] = item!;
61
+ const next = (ordinal ? `${Number(ordinal) + 1}.` : bullet) + ' ' + (box ? nextCheckbox(box) : '');
62
+ const head = `${value.slice(0, start)}\n${next}`;
63
+ const rest = value.slice(start).replace(/^ +/, '');
64
+ const tail = ordinal ? renumber(rest, Number(ordinal) + 2) : rest;
65
+ return {value: head + tail, caret: head.length};
66
+ }
67
+
68
+ /**
69
+ * Shifts the ordered items that directly follow the split line up by one, so the source keeps
70
+ * counting in step. `text` starts with the rest of the split line, which is left alone.
71
+ */
72
+ function renumber(text: string, from: number): string {
73
+ const lines = text.split('\n');
74
+ for (let i = 1, n = from; i < lines.length; i++, n++) {
75
+ if (!ORDERED_ITEM.test(lines[i])) break;
76
+ lines[i] = lines[i].replace(ORDERED_ITEM, `${n}. `);
77
+ }
78
+ return lines.join('\n');
79
+ }
@@ -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, isEmptyListItem} 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";
@@ -561,6 +562,11 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
561
562
  * delimited containers (``` ==== !!!! .... ---- ____ **** ////) as single blocks.
562
563
  */
563
564
  private splitBlocks(source: string): string[] {
565
+ return this.scanBlocks(source).blocks;
566
+ }
567
+
568
+ /** `splitBlocks`, plus whether the source ends inside an unclosed container, where a blank line does not split. */
569
+ private scanBlocks(source: string): { blocks: string[]; open: boolean } {
564
570
  const lines = source.replace(/\r\n/g, '\n').split('\n');
565
571
  const blocks: string[] = [];
566
572
  let current: string[] = [];
@@ -611,7 +617,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
611
617
  current.push(line);
612
618
  }
613
619
  push();
614
- return blocks;
620
+ return {blocks, open: fence !== null || depth > 0};
615
621
  }
616
622
 
617
623
  private fenceMarker(line: string): string | null {
@@ -954,7 +960,25 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
954
960
  // The slash menu claims navigation, Enter and Escape in the capture phase.
955
961
  if (this.slashController.open) return;
956
962
 
957
- if (e.key === 'Enter' && e.shiftKey) {
963
+ const shortcutAction = EDITOR_ACTIONS.find(action =>
964
+ action.shortcut && matchesShortcut(e, action.shortcut) && this.actionAvailable(action));
965
+ if (shortcutAction) {
966
+ e.preventDefault();
967
+ this.activateAction(shortcutAction);
968
+ } else if (this.isPlainEnter(e)) {
969
+ const finished = this.finishedDraft(input.value, input.selectionStart, input.selectionEnd);
970
+ if (finished !== null) {
971
+ e.preventDefault();
972
+ this.editingDraft = finished;
973
+ this.suppressBlurCommit = true;
974
+ this.insertDraftBlock(this.commitEdit());
975
+ return;
976
+ }
977
+ const edit = continueList(input.value, input.selectionStart, input.selectionEnd);
978
+ if (!edit) return;
979
+ e.preventDefault();
980
+ this.applyDraftEdit(input, edit.value, edit.caret, edit.caret);
981
+ } else if (e.key === 'Enter' && e.shiftKey) {
958
982
  e.preventDefault();
959
983
  this.suppressBlurCommit = true;
960
984
  const next = this.commitEdit();
@@ -968,6 +992,35 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
968
992
  }
969
993
  };
970
994
 
995
+ /**
996
+ * The draft to commit when Enter should finish the block — pressed on a trailing blank line or
997
+ * empty list item, outside any container — with that line dropped. Null otherwise.
998
+ */
999
+ private finishedDraft(value: string, start: number, end: number): string | null {
1000
+ if (start !== end || value.slice(start).trim() !== '') return null;
1001
+ const lineStart = value.lastIndexOf('\n', start - 1) + 1;
1002
+ const line = value.slice(lineStart);
1003
+ if (line.trim() !== '' && !isEmptyListItem(line)) return null;
1004
+ const before = value.slice(0, lineStart);
1005
+ if (before.trim() === '' || this.scanBlocks(before).open) return null;
1006
+ return before.trimEnd();
1007
+ }
1008
+
1009
+ private isPlainEnter(e: KeyboardEvent): boolean {
1010
+ return e.key === 'Enter' && !e.isComposing && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey;
1011
+ }
1012
+
1013
+ private handleRawKeydown = (e: KeyboardEvent) => {
1014
+ if (!this.isPlainEnter(e)) return;
1015
+ const input = e.target as HTMLTextAreaElement;
1016
+ const edit = continueList(input.value, input.selectionStart, input.selectionEnd);
1017
+ if (!edit) return;
1018
+ e.preventDefault();
1019
+ input.value = edit.value;
1020
+ input.setSelectionRange(edit.caret, edit.caret);
1021
+ this.handleRawInput(e);
1022
+ };
1023
+
971
1024
  /** Whether the block being edited is nothing but the slash command. */
972
1025
  private isSlashBlock(): boolean {
973
1026
  const value = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')?.value ?? '';
@@ -1404,7 +1457,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1404
1457
  /**
1405
1458
  * Applies an inline mark to the open block's textarea: wraps the selection, toggles the
1406
1459
  * 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.
1460
+ * no selection.
1408
1461
  */
1409
1462
  private applyInline(mark: InlineMark) {
1410
1463
  const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
@@ -1451,10 +1504,15 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1451
1504
  caret = [start + mark.before.length, start + mark.before.length + body.length];
1452
1505
  }
1453
1506
 
1454
- this.editingDraft = next;
1455
- input.value = next;
1456
- input.setSelectionRange(caret[0], caret[1]);
1457
- this.editShell = this.computeEditShell(next);
1507
+ this.applyDraftEdit(input, next, caret[0], caret[1]);
1508
+ }
1509
+
1510
+ /** Writes a programmatic edit to the open block's textarea. Goes through `editingDraft` so `zn-input` still fires. */
1511
+ private applyDraftEdit(input: HTMLTextAreaElement, value: string, selectionStart: number, selectionEnd: number) {
1512
+ this.editingDraft = value;
1513
+ input.value = value;
1514
+ input.setSelectionRange(selectionStart, selectionEnd);
1515
+ this.editShell = this.computeEditShell(value);
1458
1516
  this.autosize(input);
1459
1517
  input.focus({preventScroll: true});
1460
1518
  this.emit('zn-input');
@@ -1824,7 +1882,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1824
1882
  plain
1825
1883
  icon=${action.icon}
1826
1884
  icon-size="18"
1827
- tooltip=${action.label}
1885
+ label=${action.label}
1886
+ tooltip=${action.shortcut ? `${action.label} (${shortcutLabel(action.shortcut)})` : action.label}
1828
1887
  ?disabled=${this.isActionDisabled(action)}
1829
1888
  @mousedown=${(e: MouseEvent) => e.preventDefault()}
1830
1889
  @click=${() => this.activateAction(action)}></zn-button>`;
@@ -1838,6 +1897,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1838
1897
  @click=${() => this.activateAction(action)}>
1839
1898
  <zn-icon slot="prefix" src=${action.icon} size="18"></zn-icon>
1840
1899
  ${action.label}
1900
+ ${action.shortcut ? html`
1901
+ <span slot="suffix" class="remarkd-editor__shortcut">${shortcutLabel(action.shortcut)}</span>` : ''}
1841
1902
  </zn-menu-item>`;
1842
1903
  }
1843
1904
 
@@ -1892,7 +1953,14 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1892
1953
  tooltip="More"
1893
1954
  @mousedown=${(e: MouseEvent) => e.preventDefault()}></zn-button>
1894
1955
  <zn-menu>
1895
- ${overflowed.flatMap(entry => entry.actions).map(action => this.renderMenuAction(action))}
1956
+ ${overflowed.map(entry => html`
1957
+ <zn-menu-item>
1958
+ <zn-icon slot="prefix" src=${entry.actions[0].icon} size="18"></zn-icon>
1959
+ ${entry.group.label}
1960
+ <zn-menu slot="submenu">
1961
+ ${entry.actions.map(action => this.renderMenuAction(action))}
1962
+ </zn-menu>
1963
+ </zn-menu-item>`)}
1896
1964
  </zn-menu>
1897
1965
  </zn-dropdown>` : ''}`;
1898
1966
  })()}
@@ -1937,6 +2005,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1937
2005
  spellcheck="false"
1938
2006
  .value=${this.value}
1939
2007
  @input=${this.handleRawInput}
2008
+ @keydown=${this.handleRawKeydown}
1940
2009
  @blur=${this.commitRaw}></textarea>`;
1941
2010
  }
1942
2011
 
@@ -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
+ }