@kubex/zinc 1.1.165 → 1.1.167

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.
@@ -160,7 +160,7 @@ outgrows the 20rem cap set here."
160
160
  ### Actions
161
161
 
162
162
  The toolbar and the `/` slash menu are driven by one shared registry of actions, grouped
163
- under ten headings: **Text**, **Inline**, **Lists**, **Admonitions**, **Blocks**,
163
+ under ten headings: **Text**, **Inline**, **Lists**, **Alerts**, **Blocks**,
164
164
  **Structured**, **Media**, **Objects**, **Breaks**, and **Logic**. The toolbar shows as
165
165
  many groups as fit its width and folds the rest into an overflow menu; the slash menu
166
166
  carries every action regardless of width, and typing narrows it by label or keyword — so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.165",
3
+ "version": "1.1.167",
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",
@@ -42,7 +42,7 @@ export const ACTION_GROUPS: { id: ActionGroup; label: string }[] = [
42
42
  // they are among the last groups to collapse, not the first.
43
43
  {id: 'inline', label: 'Inline'},
44
44
  {id: 'lists', label: 'Lists'},
45
- {id: 'admonitions', label: 'Admonitions'},
45
+ {id: 'admonitions', label: 'Alerts'},
46
46
  {id: 'blocks', label: 'Blocks'},
47
47
  {id: 'structured', label: 'Structured'},
48
48
  {id: 'media', label: 'Media'},
@@ -67,7 +67,7 @@ export const EDITOR_ACTIONS: EditorAction[] = [
67
67
  {key: 'checkboxes', label: 'Checklist', icon: 'list-todo@lu', group: 'lists', prefix: '- [ ] ', keywords: ['task', 'todo']},
68
68
  {key: 'definition-list', label: 'Definition list', icon: 'book-a@lu', group: 'lists', prefix: 'Term:: Definition'},
69
69
 
70
- // Admonitions
70
+ // Alerts
71
71
  {key: 'admonition', label: 'Note', icon: 'info@lu', group: 'admonitions', prefix: 'NOTE: '},
72
72
  {key: 'tip', label: 'Tip', icon: 'lightbulb@lu', group: 'admonitions', prefix: 'TIP: '},
73
73
  {key: 'warning', label: 'Warning', icon: 'triangle-alert@lu', group: 'admonitions', prefix: 'WARNING: '},
@@ -1,4 +1,4 @@
1
- import {continueList} from './list-continuation';
1
+ import {continueList, indentList} from './list-continuation';
2
2
  import {expect} from '@open-wc/testing';
3
3
 
4
4
  /** Enter at `caret` (the end by default), rendered as the new value with `|` at the caret, or null. */
@@ -12,15 +12,23 @@ describe('continueList', () => {
12
12
  expect(enter('- a')).to.equal('- a\n- |');
13
13
  expect(enter('* a')).to.equal('* a\n* |');
14
14
  expect(enter('-- nested')).to.equal('-- nested\n-- |');
15
+ expect(enter('- a\n---- deep\n---- deeper')).to.equal('- a\n---- deep\n---- deeper\n---- |');
15
16
  });
16
17
 
17
18
  it('should continue numbered lists with the next number', () => {
18
19
  expect(enter('1. hello')).to.equal('1. hello\n2. |');
19
20
  expect(enter('9. nine')).to.equal('9. nine\n10. |');
21
+ expect(enter('1. a\n1.1. b')).to.equal('1. a\n1.1. b\n1.2. |');
22
+ });
23
+
24
+ it('should number skipped levels from one', () => {
25
+ expect(enter('1. a\n1.1.1. b\n2. c', 4)).to.equal('1. a\n2. |\n2.1.1. b\n3. c');
20
26
  });
21
27
 
22
28
  it('should renumber the items after one inserted mid-list', () => {
23
29
  expect(enter('1. a\n2. b\n3. c', 4)).to.equal('1. a\n2. |\n3. b\n4. c');
30
+ expect(enter('1. a\n1.1. b\n2. c', 4)).to.equal('1. a\n2. |\n2.1. b\n3. c');
31
+ expect(enter('1. a\n-- x\n2. b', 4)).to.equal('1. a\n2. |\n-- x\n3. b');
24
32
  });
25
33
 
26
34
  it('should continue checklists unticked, keeping the editable kind', () => {
@@ -35,6 +43,7 @@ describe('continueList', () => {
35
43
  });
36
44
 
37
45
  it('should end the list when Enter is pressed on an empty item', () => {
46
+ expect(enter('1. a\n1.1. ')).to.equal('1. a\n|');
38
47
  expect(enter('- a\n- ')).to.equal('- a\n|');
39
48
  expect(enter('1. a\n2. ')).to.equal('1. a\n|');
40
49
  expect(enter('- [ ] a\n- [ ] ')).to.equal('- [ ] a\n|');
@@ -65,3 +74,70 @@ describe('continueList', () => {
65
74
  expect(continueList('- abc', 2, 4)).to.be.null;
66
75
  });
67
76
  });
77
+
78
+ /** Tab over `[start, end]` (a caret at the end by default), rendered with `[`/`]` around the selection, or null. */
79
+ function tab(value: string, outdent = false, start = value.length, end = start): string | null {
80
+ const edit = indentList(value, start, end, outdent);
81
+ if (!edit) return null;
82
+ const v = edit.value;
83
+ return edit.start === edit.end
84
+ ? `${v.slice(0, edit.start)}|${v.slice(edit.start)}`
85
+ : `${v.slice(0, edit.start)}[${v.slice(edit.start, edit.end)}]${v.slice(edit.end)}`;
86
+ }
87
+
88
+ describe('indentList', () => {
89
+ it('should nest a bullet item one level deeper on Tab', () => {
90
+ expect(tab('- a')).to.equal('-- a|');
91
+ expect(tab('* a')).to.equal('** a|');
92
+ expect(tab('- [ ] task')).to.equal('-- [ ] task|');
93
+ expect(tab('- a\n- b')).to.equal('- a\n-- b|');
94
+ expect(tab('---- a\n---- b')).to.equal('---- a\n----- b|');
95
+ });
96
+
97
+ it('should un-nest a bullet item on Shift+Tab, stopping at the top level', () => {
98
+ expect(tab('--- a', true)).to.equal('-- a|');
99
+ expect(tab('- a', true)).to.equal('- a|');
100
+ });
101
+
102
+ it('should stop at the deepest level the parser supports', () => {
103
+ expect(tab('---------- a')).to.equal('---------- a|');
104
+ });
105
+
106
+ it('should keep the caret on the same text', () => {
107
+ expect(tab('- hello', false, 4)).to.equal('-- he|llo');
108
+ expect(tab('-- hello', true, 5)).to.equal('- he|llo');
109
+ expect(tab('- a', false, 0)).to.equal('|-- a');
110
+ });
111
+
112
+ it('should nest every bullet item a selection touches', () => {
113
+ const value = '- a\n- b\n- c';
114
+ expect(tab(value, false, 4, 11)).to.equal('- a\n[-- b\n-- c]');
115
+ expect(tab(value, false, 0, 4)).to.equal('[-- a\n]- b\n- c');
116
+ });
117
+
118
+ it('should nest a numbered item under the one above, renumbering the items after it', () => {
119
+ expect(tab('1. a\n2. b')).to.equal('1. a\n1.1. b|');
120
+ expect(tab('1. a\n2. b\n3. c', false, 9)).to.equal('1. a\n1.1. b|\n2. c');
121
+ expect(tab('1. a\n1.1. b\n2. c')).to.equal('1. a\n1.1. b\n1.2. c|');
122
+ expect(tab('1. a\n-- x\n2. b')).to.equal('1. a\n-- x\n1.1. b|');
123
+ expect(tab('1. first')).to.equal('1. first|');
124
+ expect(tab('1. a\n1.1. b')).to.equal('1. a\n1.1. b|');
125
+ expect(tab('1. a\n1.1. b\n1.2. c')).to.equal('1. a\n1.1. b\n1.1.1. c|');
126
+ });
127
+
128
+ it('should un-nest a numbered item on Shift+Tab, adopting the items after it', () => {
129
+ expect(tab('1. a\n1.1. b\n1.2. c\n2. d', true, 10)).to.equal('1. a\n2. |b\n2.1. c\n3. d');
130
+ expect(tab('1. a', true)).to.equal('1. a|');
131
+ });
132
+
133
+ it('should keep the caret on the same text when the marker changes length', () => {
134
+ expect(tab('1. a\n2. hello', false, 10)).to.equal('1. a\n1.1. he|llo');
135
+ expect(tab('1. a\n1.1. hello', true, 12)).to.equal('1. a\n2. he|llo');
136
+ });
137
+
138
+ it('should leave Tab alone outside list items', () => {
139
+ expect(tab('plain text')).to.be.null;
140
+ expect(tab('CPU:: x')).to.be.null;
141
+ expect(tab('```\n- a')).to.be.null;
142
+ });
143
+ });
@@ -4,14 +4,15 @@ export interface TextEdit {
4
4
  caret: number;
5
5
  }
6
6
 
7
- // Nesting depth is the marker's repeat count (`--`, `**`), never indentation.
8
- const LIST_ITEM = /^(-+|\*+|(\d+)\.) (\[[ xX_*]\] )?/;
9
- const ORDERED_ITEM = /^(\d+)\. /;
7
+ // Nesting depth is the marker's repeat count (`--`, `**`) or dot count (`1.2.`), never indentation.
8
+ const LIST_ITEM = /^(-+|\*+|((?:\d+\.)+)) (\[[ xX_*]\] )?/;
9
+ const ORDERED_PATH = /^(?:\d+\.)+(?= )/;
10
10
  // `::` must end the line or be followed by a space, so macros (`include::x[]`) never match.
11
11
  const DEFINITION = /^[^:\s][^\n]*?::(?: |$)/;
12
12
 
13
13
  // Code, listing and literal blocks — their content is verbatim, so a `- ` there is not a list.
14
- const VERBATIM_DELIMITER = /^(`{3,}|-{4,}|\.{4,})/;
14
+ // A `----`/`....` delimiter is the whole line; `---- x` is a fourth-level bullet.
15
+ const VERBATIM_DELIMITER = /^(?:`{3,}|-{4,} *$|\.{4,} *$)/;
15
16
 
16
17
  function insideVerbatim(before: string): boolean {
17
18
  return before.split('\n').filter(line => VERBATIM_DELIMITER.test(line)).length % 2 === 1;
@@ -58,22 +59,122 @@ export function continueList(value: string, start: number, end: number): TextEdi
58
59
  }
59
60
 
60
61
  const [, bullet, ordinal, box] = item!;
61
- const next = (ordinal ? `${Number(ordinal) + 1}.` : bullet) + ' ' + (box ? nextCheckbox(box) : '');
62
+ const own = ordinal ? parsePath(ordinal) : null;
63
+ const path = own && nextPath(own, own.length);
64
+ const next = (path ? formatPath(path) : bullet) + ' ' + (box ? nextCheckbox(box) : '');
62
65
  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
+ const edited = head + value.slice(start).replace(/^ +/, '');
67
+ if (!path) return {value: edited, caret: head.length};
68
+
69
+ const lines = edited.split('\n');
70
+ renumber(lines, head.split('\n').length);
71
+ return {value: lines.join('\n'), caret: head.length};
72
+ }
73
+
74
+ /** The numbers of an ordered marker: `1.2.` is `[1, 2]`. */
75
+ function parsePath(marker: string): number[] {
76
+ return marker.split('.').filter(Boolean).map(Number);
77
+ }
78
+
79
+ function formatPath(path: number[]): string {
80
+ return path.map(n => `${n}.`).join('');
81
+ }
82
+
83
+ /** The marker of the ordered item at `depth` that follows the one numbered `previous`. */
84
+ function nextPath(previous: number[], depth: number): number[] {
85
+ if (depth <= previous.length) return [...previous.slice(0, depth - 1), previous[depth - 1] + 1];
86
+ return [...previous, ...Array<number>(depth - previous.length).fill(1)];
87
+ }
88
+
89
+ /** The number of the nearest ordered item above line `i` in the same list, passing over nested bullets. */
90
+ function orderedAbove(lines: string[], i: number): number[] | null {
91
+ for (let j = i - 1; j >= 0; j--) {
92
+ const item = LIST_ITEM.exec(lines[j]);
93
+ if (!item) return null;
94
+ if (item[2]) return parsePath(item[2]);
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Renumbers the ordered items from line `from` on as an outline counting on from the item above,
101
+ * so the source stays in step with the rendered numbering. Bullet items nested among them are
102
+ * passed over; any other line ends the list. With no ordered item above, the first keeps its
103
+ * number.
104
+ */
105
+ function renumber(lines: string[], from: number) {
106
+ let path = orderedAbove(lines, from);
107
+ for (let i = from; i < lines.length; i++) {
108
+ const item = LIST_ITEM.exec(lines[i]);
109
+ if (!item) break;
110
+ if (!item[2]) continue;
111
+ const own = parsePath(item[2]);
112
+ path = path ? nextPath(path, own.length) : own;
113
+ lines[i] = lines[i].replace(ORDERED_PATH, formatPath(path));
114
+ }
66
115
  }
67
116
 
117
+ /** A textarea edit that keeps a selection: the new value and the selected range in it. */
118
+ export interface RangeEdit {
119
+ value: string;
120
+ start: number;
121
+ end: number;
122
+ }
123
+
124
+ // The parser caps bullet depth at ten marker characters.
125
+ const MAX_BULLET_DEPTH = 10;
126
+
68
127
  /**
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.
128
+ * What Tab (or Shift+Tab when `outdent`) does on the list items the selection touches: nests
129
+ * each one level deeper or shallower, renumbering the ordered items that follow. Null when no
130
+ * list item is touched, so Tab keeps its default. Items already at the limit are left as they
131
+ * are, but the key is still claimed.
71
132
  */
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}. `);
133
+ export function indentList(value: string, start: number, end: number, outdent: boolean): RangeEdit | null {
134
+ const firstStart = value.lastIndexOf('\n', start - 1) + 1;
135
+ if (insideVerbatim(value.slice(0, firstStart))) return null;
136
+ // A selection ending at the start of a line does not touch that line.
137
+ const last = end > start && value[end - 1] === '\n' ? end - 1 : end;
138
+
139
+ const before = value.split('\n');
140
+ const lines = [...before];
141
+ const first = value.slice(0, firstStart).split('\n').length - 1;
142
+ const lastIndex = value.slice(0, last).split('\n').length - 1;
143
+
144
+ let touched = false;
145
+ let renumberFrom = -1;
146
+ for (let i = first; i <= lastIndex; i++) {
147
+ const item = LIST_ITEM.exec(lines[i]);
148
+ if (!item) continue;
149
+ touched = true;
150
+ const [, bullet, ordinal] = item;
151
+ if (ordinal) {
152
+ const own = parsePath(ordinal);
153
+ // An item nests at most one level below the item above it.
154
+ const deepest = (orderedAbove(lines, i)?.length ?? 0) + 1;
155
+ if (outdent ? own.length === 1 : own.length >= deepest) continue;
156
+ lines[i] = lines[i].replace(ORDERED_PATH, formatPath(outdent ? own.slice(0, -1) : [...own, 1]));
157
+ if (renumberFrom === -1) renumberFrom = i;
158
+ } else if (outdent ? bullet.length > 1 : bullet.length < MAX_BULLET_DEPTH) {
159
+ lines[i] = outdent ? lines[i].slice(1) : bullet[0] + lines[i];
160
+ }
77
161
  }
78
- return lines.join('\n');
162
+ if (!touched) return null;
163
+ if (renumberFrom !== -1) renumber(lines, renumberFrom);
164
+
165
+ const move = (pos: number) => {
166
+ let oldAt = 0;
167
+ let newAt = 0;
168
+ for (let i = 0; i < before.length; i++) {
169
+ const oldEnd = oldAt + before[i].length;
170
+ if (pos <= oldEnd) {
171
+ const delta = lines[i].length - before[i].length;
172
+ return pos > oldAt ? Math.max(newAt, pos + (newAt - oldAt) + delta) : newAt;
173
+ }
174
+ oldAt = oldEnd + 1;
175
+ newAt += lines[i].length + 1;
176
+ }
177
+ return pos;
178
+ };
179
+ return {value: lines.join('\n'), start: move(start), end: move(end)};
79
180
  }
@@ -1,6 +1,6 @@
1
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
+ import {continueList, indentList, isEmptyListItem} from './list-continuation';
4
4
  import {type CSSResultGroup, html, type PropertyValues, type TemplateResult, unsafeCSS} from 'lit';
5
5
  import {defaultValue} from "../../internal/default-value";
6
6
  import {FormControlController} from "../../internal/form";
@@ -15,6 +15,7 @@ import ZnMenu from "../menu";
15
15
  import ZnMenuItem from "../menu-item";
16
16
  import ZnSlashMenu, {SlashMenuController, type SlashMenuItem} from "../slash-menu";
17
17
  import type {EditorAction, InlineMark} from './actions';
18
+ import type {RangeEdit} from './list-continuation';
18
19
  import type {ZincFormControl} from '../../internal/zinc-element';
19
20
  import type ZnFile from "../file";
20
21
 
@@ -978,6 +979,11 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
978
979
  if (!edit) return;
979
980
  e.preventDefault();
980
981
  this.applyDraftEdit(input, edit.value, edit.caret, edit.caret);
982
+ } else if (this.isListTab(e)) {
983
+ const edit = indentList(input.value, input.selectionStart, input.selectionEnd, e.shiftKey);
984
+ if (!edit) return;
985
+ e.preventDefault();
986
+ this.applyDraftEdit(input, edit.value, edit.start, edit.end);
981
987
  } else if (e.key === 'Enter' && e.shiftKey) {
982
988
  e.preventDefault();
983
989
  this.suppressBlurCommit = true;
@@ -1010,14 +1016,23 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1010
1016
  return e.key === 'Enter' && !e.isComposing && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey;
1011
1017
  }
1012
1018
 
1019
+ private isListTab(e: KeyboardEvent): boolean {
1020
+ return e.key === 'Tab' && !e.isComposing && !e.metaKey && !e.ctrlKey && !e.altKey;
1021
+ }
1022
+
1013
1023
  private handleRawKeydown = (e: KeyboardEvent) => {
1014
- if (!this.isPlainEnter(e)) return;
1015
1024
  const input = e.target as HTMLTextAreaElement;
1016
- const edit = continueList(input.value, input.selectionStart, input.selectionEnd);
1025
+ let edit: RangeEdit | null = null;
1026
+ if (this.isPlainEnter(e)) {
1027
+ const next = continueList(input.value, input.selectionStart, input.selectionEnd);
1028
+ edit = next && {value: next.value, start: next.caret, end: next.caret};
1029
+ } else if (this.isListTab(e)) {
1030
+ edit = indentList(input.value, input.selectionStart, input.selectionEnd, e.shiftKey);
1031
+ }
1017
1032
  if (!edit) return;
1018
1033
  e.preventDefault();
1019
1034
  input.value = edit.value;
1020
- input.setSelectionRange(edit.caret, edit.caret);
1035
+ input.setSelectionRange(edit.start, edit.end);
1021
1036
  this.handleRawInput(e);
1022
1037
  };
1023
1038
 
@@ -649,6 +649,43 @@ Second"></zn-remarkd-editor>`);
649
649
  await el.updateComplete;
650
650
  expect(el.value).to.equal('- [ ] first\n- [ ] ');
651
651
  });
652
+
653
+ const tab = (input: HTMLTextAreaElement, shiftKey = false) => {
654
+ const event = new KeyboardEvent('keydown', {key: 'Tab', shiftKey, bubbles: true, composed: true, cancelable: true});
655
+ input.dispatchEvent(event);
656
+ return event;
657
+ };
658
+
659
+ it('should nest and un-nest a list item on Tab and Shift+Tab in the open block', async () => {
660
+ const {el, input} = await editDraft('- a\n- b');
661
+ expect(tab(input).defaultPrevented).to.be.true;
662
+ await el.updateComplete;
663
+ expect(input.value).to.equal('- a\n-- b');
664
+ expect(input.selectionStart).to.equal(8);
665
+
666
+ expect(tab(input, true).defaultPrevented).to.be.true;
667
+ await el.updateComplete;
668
+ expect(input.value).to.equal('- a\n- b');
669
+ });
670
+
671
+ it('should leave Tab alone outside a list', async () => {
672
+ const {input} = await editDraft('hello');
673
+ expect(tab(input).defaultPrevented).to.be.false;
674
+ });
675
+
676
+ it('should nest a list item on Tab in the raw source view', async () => {
677
+ const el = await fixture<ZnRemarkdEditor>(html`
678
+ <zn-remarkd-editor allow-raw value="- first"></zn-remarkd-editor>`);
679
+ el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')!
680
+ .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
681
+ await el.updateComplete;
682
+ const raw = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__raw')!;
683
+ raw.setSelectionRange(7, 7);
684
+
685
+ expect(tab(raw).defaultPrevented).to.be.true;
686
+ await el.updateComplete;
687
+ expect(el.value).to.equal('-- first');
688
+ });
652
689
  });
653
690
 
654
691
  it('should insert a placeholder when nothing is selected', async () => {
@@ -915,7 +952,7 @@ Second"></zn-remarkd-editor>`);
915
952
 
916
953
  const menu = el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu')!;
917
954
  expect(menu.items.length).to.equal(EDITOR_ACTIONS.length);
918
- expect(menu.items.some(item => item.group === 'Admonitions')).to.be.true;
955
+ expect(menu.items.some(item => item.group === 'Alerts')).to.be.true;
919
956
  });
920
957
 
921
958
  it('should omit the Include action from the slash menu without an include-url', async () => {