@kubex/zinc 1.1.101 → 1.1.103

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.
Files changed (82) hide show
  1. package/dist/custom-elements.json +261 -82
  2. package/dist/vscode.html-custom-data.json +13 -13
  3. package/dist/web-types.json +25 -25
  4. package/dist/zn.d.ts +128 -6
  5. package/dist/zn.min.css +1 -1
  6. package/dist/zn.min.js +469 -393
  7. package/docs/pages/components/remarkd-editor.md +59 -0
  8. package/package.json +1 -1
  9. package/scss/_root.scss +121 -8
  10. package/scss/shared/_base.scss +10 -4
  11. package/scss/shared/_button-mixin.scss +29 -0
  12. package/scss/shared/_button.scss +3 -33
  13. package/scss/themes/_aura.scss +87 -8
  14. package/scss/themes/_dark.scss +14 -2
  15. package/scss/themes/_light.scss +4 -3
  16. package/scss/themes/_modes.scss +32 -0
  17. package/src/components/animated-button/animated-button.component.ts +2 -1
  18. package/src/components/animated-button/animated-button.scss +1 -1
  19. package/src/components/bulk-actions/bulk-actions.component.ts +2 -1
  20. package/src/components/bulk-actions/bulk-actions.scss +0 -1
  21. package/src/components/button/button.component.ts +6 -1
  22. package/src/components/button/button.scss +1 -1
  23. package/src/components/chart/chart.component.ts +4 -0
  24. package/src/components/checkbox/checkbox.component.ts +3 -1
  25. package/src/components/checkbox/checkbox.scss +0 -2
  26. package/src/components/checkbox-group/checkbox-group.component.ts +2 -1
  27. package/src/components/checkbox-group/checkbox-group.scss +0 -1
  28. package/src/components/data-table/data-table.component.ts +3 -1
  29. package/src/components/data-table/data-table.scss +0 -1
  30. package/src/components/datepicker/datepicker.component.ts +2 -1
  31. package/src/components/datepicker/datepicker.scss +0 -1
  32. package/src/components/editor/modules/toolbar/toolbar.component.ts +3 -1
  33. package/src/components/editor/modules/toolbar/toolbar.scss +0 -1
  34. package/src/components/file/file.component.ts +2 -1
  35. package/src/components/file/file.scss +0 -1
  36. package/src/components/form-group/form-group.component.ts +2 -1
  37. package/src/components/form-group/form-group.scss +0 -1
  38. package/src/components/icon-picker/icon-picker.component.ts +8 -3
  39. package/src/components/icon-picker/icon-picker.scss +0 -1
  40. package/src/components/inline-edit/inline-edit.component.ts +2 -1
  41. package/src/components/inline-edit/inline-edit.scss +0 -1
  42. package/src/components/input/input.component.ts +2 -1
  43. package/src/components/input/input.scss +0 -1
  44. package/src/components/input-group/input-group.component.ts +2 -1
  45. package/src/components/input-group/input-group.scss +0 -1
  46. package/src/components/menu/menu.component.ts +4 -1
  47. package/src/components/menu/menu.scss +9 -1
  48. package/src/components/page/page.scss +19 -5
  49. package/src/components/priority-list/priority-list.component.ts +2 -1
  50. package/src/components/priority-list/priority-list.scss +0 -1
  51. package/src/components/query-builder/query-builder.component.ts +2 -1
  52. package/src/components/query-builder/query-builder.scss +0 -1
  53. package/src/components/radio/radio.component.ts +3 -1
  54. package/src/components/radio/radio.scss +0 -2
  55. package/src/components/radio-group/radio-group.component.ts +2 -1
  56. package/src/components/radio-group/radio-group.scss +0 -1
  57. package/src/components/rating/rating.component.ts +2 -1
  58. package/src/components/rating/rating.scss +0 -1
  59. package/src/components/remarkd-editor/actions.ts +155 -0
  60. package/src/components/remarkd-editor/feature-keys.ts +17 -0
  61. package/src/components/remarkd-editor/remarkd-editor.component.ts +509 -49
  62. package/src/components/remarkd-editor/remarkd-editor.scss +68 -1
  63. package/src/components/remarkd-editor/remarkd-editor.test.ts +677 -9
  64. package/src/components/schedule-builder/schedule-builder.component.ts +2 -1
  65. package/src/components/schedule-builder/schedule-builder.scss +0 -1
  66. package/src/components/select/select.component.ts +2 -1
  67. package/src/components/select/select.scss +0 -1
  68. package/src/components/slash-menu/slash-menu.scss +2 -0
  69. package/src/components/tabs/tabs.component.ts +3 -1
  70. package/src/components/tabs/tabs.scss +0 -1
  71. package/src/components/textarea/textarea.component.ts +2 -1
  72. package/src/components/textarea/textarea.scss +0 -1
  73. package/src/components/tile/tile.component.ts +33 -20
  74. package/src/components/tile/tile.scss +2 -0
  75. package/src/components/tile/tile.test.ts +64 -3
  76. package/src/components/toggle/toggle.component.ts +2 -1
  77. package/src/components/toggle/toggle.scss +0 -1
  78. package/src/components/translations/translations.component.ts +2 -1
  79. package/src/components/translations/translations.scss +0 -1
  80. package/src/internal/theme.ts +37 -12
  81. package/src/internal/toolbar-overflow.ts +100 -0
  82. package/src/internal/zinc-element.ts +7 -5
@@ -1,13 +1,19 @@
1
+ import {ACTION_GROUPS, EDITOR_ACTIONS, slashItems} from './actions';
1
2
  import {classMap} from "lit/directives/class-map.js";
2
- import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
3
+ import {type CSSResultGroup, html, type PropertyValues, type TemplateResult, unsafeCSS} from 'lit';
3
4
  import {defaultValue} from "../../internal/default-value";
4
5
  import {FormControlController} from "../../internal/form";
5
6
  import {property, query, state} from 'lit/decorators.js';
6
7
  import {parse as remarkdParse} from "remarkd-js";
8
+ import {ToolbarOverflowController} from '../../internal/toolbar-overflow';
7
9
  import {unsafeHTML} from "lit/directives/unsafe-html.js";
8
10
  import {watch} from "../../internal/watch";
9
11
  import ZincElement from '../../internal/zinc-element';
12
+ import ZnDropdown from "../dropdown";
13
+ import ZnMenu from "../menu";
14
+ import ZnMenuItem from "../menu-item";
10
15
  import ZnSlashMenu, {SlashMenuController, type SlashMenuItem} from "../slash-menu";
16
+ import type {EditorAction, InlineMark} from './actions';
11
17
  import type {ZincFormControl} from '../../internal/zinc-element';
12
18
  import type ZnFile from "../file";
13
19
 
@@ -19,16 +25,6 @@ interface UploadResponse {
19
25
  originalFilename: string;
20
26
  }
21
27
 
22
- interface BlockType {
23
- label: string;
24
- icon: string;
25
- prefix?: string;
26
- /** Where the caret lands within `prefix`. Defaults to the end. */
27
- caretOffset?: number;
28
- image?: boolean;
29
- include?: boolean;
30
- }
31
-
32
28
  interface ImageBlockData {
33
29
  caption: string;
34
30
  align: '' | 'center' | 'right';
@@ -53,22 +49,70 @@ interface IncludeOption {
53
49
  url?: string;
54
50
  }
55
51
 
56
- const BLOCK_TYPES: BlockType[] = [
57
- {label: 'Text', icon: 'text@lu', prefix: ''},
58
- {label: 'Heading 1', icon: 'heading-1@lu', prefix: '# '},
59
- {label: 'Heading 2', icon: 'heading-2@lu', prefix: '## '},
60
- {label: 'Heading 3', icon: 'heading-3@lu', prefix: '### '},
61
- {label: 'Note', icon: 'info@lu', prefix: 'NOTE: '},
62
- {label: 'Tip', icon: 'lightbulb@lu', prefix: 'TIP: '},
63
- {label: 'Warning', icon: 'triangle-alert@lu', prefix: 'WARNING: '},
64
- {label: 'Code', icon: 'code@lu', prefix: '```\n\n```', caretOffset: 4},
65
- {label: 'Image', icon: 'image@lu', image: true},
66
- {label: 'Include', icon: 'blocks@lu', include: true},
67
- ];
68
-
69
52
  /** remarkd's include directive on a line of its own: `include::<target>[<label>]`. */
70
53
  const INCLUDE_LINE = /^include::([^[]+)\[(.*)]\s*$/;
71
54
 
55
+ /** A document attribute definition: `:name: value`, `:flag:`, or `:flag!:` to unset. */
56
+ const ATTRIBUTE_LINE = /^:([A-Za-z0-9_-]+)(!)?:\s*(.*)$/;
57
+
58
+ /** A title line decorating whatever follows it: `.Title text`. `..` escapes a literal leading dot. */
59
+ const TITLE_LINE = /^\.([^.\s].*)$/;
60
+
61
+ /** A bracketed attribute/parameter line: `[.lead]`, `[%hardbreaks]`, `[verse]`, `[striped=true]`. */
62
+ const BRACKET_LINE = /^\[.+]$/;
63
+
64
+ /** A metadata line that renders empty on its own — an attribute, a title, or a bracket line. */
65
+ type MetaLine =
66
+ | { kind: 'attribute'; name: string; value: string; unset: boolean }
67
+ | { kind: 'title'; text: string }
68
+ | { kind: 'bracket'; text: string };
69
+
70
+ function parseMetaLine(line: string): MetaLine | null {
71
+ const attribute = ATTRIBUTE_LINE.exec(line);
72
+ if (attribute) return {kind: 'attribute', name: attribute[1], value: attribute[3], unset: !!attribute[2]};
73
+ const title = TITLE_LINE.exec(line);
74
+ if (title) return {kind: 'title', text: title[1]};
75
+ if (BRACKET_LINE.test(line)) return {kind: 'bracket', text: line};
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * A block that is nothing but metadata lines parses to an empty section — e.g. a lone
81
+ * `.Title` or `:name: value`. Mixed content (a title immediately followed by its body, no
82
+ * blank line) parses correctly on its own and must not be caught here.
83
+ */
84
+ function parseMetaBlock(block: string): MetaLine[] | null {
85
+ const lines = block.split('\n').map(line => line.trim()).filter(line => line !== '');
86
+ if (!lines.length) return null;
87
+ const parsed: MetaLine[] = [];
88
+ for (const line of lines) {
89
+ const meta = parseMetaLine(line);
90
+ if (!meta) return null;
91
+ parsed.push(meta);
92
+ }
93
+ return parsed;
94
+ }
95
+
96
+ /** A remarkd table delimiter. Rows are separated by blank lines, so the whole table is one block. */
97
+ const TABLE_FENCE = '|===';
98
+ const TABLE_FENCE_LINE = /^\|={3,}$/;
99
+
100
+ /**
101
+ * Block-form conditionals: empty brackets open a range closed by `endif::[]`.
102
+ * The inline forms (`iftrue::flag[text]`) carry their text in the brackets and stay single lines.
103
+ */
104
+ const CONDITIONAL_OPEN = /^(?:ifdef|ifndef)::[^[]*\[]$/;
105
+ const IFEVAL_OPEN = /^ifeval::\[[^\]]*]$/;
106
+ const CONDITIONAL_END = /^endif::\[]$/;
107
+
108
+ /**
109
+ * Single-line conditional forms carry their content inside non-empty brackets and evaluate
110
+ * immediately if handed to the parser verbatim: iftrue/iffalse/ifnempty match no parser rule
111
+ * (rendering an empty section) and ifempty is genuinely evaluated. Marked the same way as the
112
+ * block forms instead — never evaluated.
113
+ */
114
+ const CONDITIONAL_INLINE = /^(ifdef|ifndef|iftrue|iffalse|ifempty|ifnempty)::([^[]+)\[([^\]]+)]\s*$/;
115
+
72
116
  /**
73
117
  * The marker a document body carries for an embed. Mirrors app-kb's
74
118
  * `model.IncludeEmbedMarker`: brackets and newlines in the title would break the
@@ -79,10 +123,162 @@ function includeMarker(id: string, title: string): string {
79
123
  return `include::${id}[${label}]`;
80
124
  }
81
125
 
82
- /** The block types as slash menu insertions. Image opens the picker, so it inserts nothing. */
83
- const SLASH_ITEMS: SlashMenuItem[] = BLOCK_TYPES.map(item => item.image || item.include
84
- ? {label: item.label, icon: item.icon, action: item.image ? 'image' : 'include'}
85
- : {label: item.label, icon: item.icon, value: item.prefix ?? '', caretOffset: item.caretOffset});
126
+ const SLASH_ITEMS = slashItems(EDITOR_ACTIONS);
127
+
128
+ /**
129
+ * For an asymmetric mark whose closer embeds a variable payload in parentheses — Link's
130
+ * `](https://)`, Tooltip's `}(Explanation)` — matches the closer by its fixed prefix rather
131
+ * than the placeholder text, so a real URL or explanation of a different length still counts
132
+ * as "already wrapped". Returns null for a mark with no such payload (Keyboard, Footnote,
133
+ * Cross reference, …), which keeps comparing `after` literally.
134
+ */
135
+ function closerPattern(after: string): RegExp | null {
136
+ const paren = after.indexOf('(');
137
+ if (paren === -1 || !after.endsWith(')')) return null;
138
+ const fixed = after.slice(0, paren).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
139
+ return new RegExp(`^${fixed}\\([^)]*\\)`);
140
+ }
141
+
142
+ /** The parser throws on some incomplete input — `t::partial::` with no path raises EISDIR. */
143
+ function safeParse(source: string): string {
144
+ try {
145
+ return remarkdParse(source);
146
+ } catch {
147
+ // Show the source as-is rather than losing the block while it is being typed.
148
+ return `<pre class="remarkd-editor__unparsed">${source.replace(/[&<>]/g,
149
+ ch => ({'&': '&amp;', '<': '&lt;', '>': '&gt;'}[ch]!))}</pre>`;
150
+ }
151
+ }
152
+
153
+ /** A conditional's interior, split so no directive line ever reaches the parser (see below). */
154
+ type ConditionalPart =
155
+ | { kind: 'content'; lines: string[] }
156
+ | { kind: 'nested'; label: string; parts: ConditionalPart[] };
157
+
158
+ /**
159
+ * Depth-aware search for the `endif::[]` that closes the range opened at `lines[from - 1]`,
160
+ * mirroring the block splitter's own depth tracking above. Returns -1 for an unclosed range
161
+ * (which still must show its content, so callers treat that as "to the end").
162
+ */
163
+ function findConditionalEnd(lines: string[], from: number): number {
164
+ let depth = 1;
165
+ for (let i = from; i < lines.length; i++) {
166
+ const trimmed = lines[i].trimEnd();
167
+ if (CONDITIONAL_OPEN.test(trimmed) || IFEVAL_OPEN.test(trimmed)) depth++;
168
+ else if (CONDITIONAL_END.test(trimmed) && --depth === 0) return i;
169
+ }
170
+ return -1;
171
+ }
172
+
173
+ /**
174
+ * Wording for what shows a conditional's content, keyed by directive — shared by the
175
+ * block-range label below and the single-line form's label.
176
+ */
177
+ const CONDITIONAL_VERB: Record<string, string> = {
178
+ ifdef: 'is defined',
179
+ ifndef: 'is not defined',
180
+ iftrue: 'is true',
181
+ iffalse: 'is false',
182
+ ifempty: 'is empty',
183
+ ifnempty: 'is not empty',
184
+ };
185
+
186
+ /** The label describing when a conditional's content would apply, given its opening line. */
187
+ function conditionalLabel(open: string): string {
188
+ if (IFEVAL_OPEN.test(open)) return `shown when ${open.slice('ifeval::['.length, -1)}`;
189
+ const directive = open.slice(0, open.indexOf('::'));
190
+ const expression = open.slice(open.indexOf('::') + 2, open.indexOf('['));
191
+ return `shown when ${expression} ${CONDITIONAL_VERB[directive] ?? 'is defined'}`;
192
+ }
193
+
194
+ /** The label for a single-line conditional, in the same wording as the block forms. */
195
+ function inlineConditionalLabel(directive: string, flag: string): string {
196
+ return `shown when ${flag} ${CONDITIONAL_VERB[directive] ?? 'is defined'}`;
197
+ }
198
+
199
+ /**
200
+ * Splits a conditional's interior into plain-content runs and nested conditional ranges.
201
+ * `parse()` evaluates any directive line it sees, so a nested `ifdef`/`endif` pair handed to
202
+ * it verbatim renders empty — this walk keeps every directive line out of what gets parsed,
203
+ * however deep the nesting, so content is never lost to an unintentional evaluation.
204
+ */
205
+ function splitConditionalParts(lines: string[]): ConditionalPart[] {
206
+ const parts: ConditionalPart[] = [];
207
+ let buffer: string[] = [];
208
+ const flush = () => {
209
+ if (buffer.length) parts.push({kind: 'content', lines: buffer});
210
+ buffer = [];
211
+ };
212
+
213
+ for (let i = 0; i < lines.length; i++) {
214
+ const trimmed = lines[i].trimEnd();
215
+ if (CONDITIONAL_OPEN.test(trimmed) || IFEVAL_OPEN.test(trimmed)) {
216
+ flush();
217
+ const end = findConditionalEnd(lines, i + 1);
218
+ const nestedInterior = lines.slice(i + 1, end === -1 ? undefined : end);
219
+ parts.push({kind: 'nested', label: conditionalLabel(trimmed), parts: splitConditionalParts(nestedInterior)});
220
+ i = end === -1 ? lines.length : end;
221
+ continue;
222
+ }
223
+ buffer.push(lines[i]);
224
+ }
225
+ flush();
226
+ return parts;
227
+ }
228
+
229
+ /**
230
+ * Renders a conditional's parts. A lone content run (the common case) keeps a single body
231
+ * div carrying the `remarkd-editor__rendered--parsed` marker directly — its content is pure
232
+ * parser output with no Lit bindings inside, so `markVariables` can safely mutate its text
233
+ * nodes. Once there is more than one part, or any nesting, the body becomes a plain
234
+ * container instead and the marker moves onto each content-run div individually: a nested
235
+ * wrapper's label is a Lit string binding, and the marker must never sit on an element whose
236
+ * subtree contains one, or a repeat render corrupts it the way Task 8 fixed.
237
+ */
238
+ function renderConditionalParts(parts: ConditionalPart[]): TemplateResult {
239
+ const lone = parts.length === 1 && parts[0].kind === 'content' ? parts[0] : undefined;
240
+ if (parts.length === 0 || lone) {
241
+ const text = lone?.lines.join('\n').trim() ?? '';
242
+ return html`
243
+ <div class="remarkd-editor__conditional-body remarkd-rendered remarkd-editor__rendered--parsed">
244
+ ${text ? unsafeHTML(safeParse(text)) : ''}
245
+ </div>`;
246
+ }
247
+
248
+ return html`
249
+ <div class="remarkd-editor__conditional-body remarkd-rendered">
250
+ ${parts.map(part => {
251
+ if (part.kind === 'nested') return renderConditionalWrapper(part.label, part.parts);
252
+ const text = part.lines.join('\n').trim();
253
+ return text ? html`<div class="remarkd-editor__rendered--parsed">${unsafeHTML(safeParse(text))}</div>` : '';
254
+ })}
255
+ </div>`;
256
+ }
257
+
258
+ function renderConditionalWrapper(label: string, parts: ConditionalPart[]): TemplateResult {
259
+ return html`
260
+ <div part="conditional" class="remarkd-editor__conditional">
261
+ <span class="remarkd-editor__conditional-label">
262
+ <zn-icon src="git-branch@lu" size="14"></zn-icon>${label}
263
+ </span>
264
+ ${renderConditionalParts(parts)}
265
+ </div>`;
266
+ }
267
+
268
+ /**
269
+ * A single-line conditional (`iftrue::flag[Shown]`) as the same labelled wrapper the block
270
+ * forms use. Handed to the parser verbatim it either renders an empty section (iftrue,
271
+ * iffalse, ifnempty match no parser rule) or is genuinely evaluated (ifempty) — so the
272
+ * bracket text is parsed on its own and marked, never evaluated, exactly like the block forms.
273
+ */
274
+ function renderInlineConditional(block: string): TemplateResult | null {
275
+ const trimmed = block.trim();
276
+ if (trimmed.includes('\n')) return null;
277
+ const match = CONDITIONAL_INLINE.exec(trimmed);
278
+ if (!match) return null;
279
+ const [, directive, flag, text] = match;
280
+ return renderConditionalWrapper(inlineConditionalLabel(directive, flag), [{kind: 'content', lines: [text]}]);
281
+ }
86
282
 
87
283
  /**
88
284
  * @summary A Notion-style block editor for remarkd content. Blocks render inline; click one to edit its source.
@@ -92,18 +288,23 @@ const SLASH_ITEMS: SlashMenuItem[] = BLOCK_TYPES.map(item => item.image || item.
92
288
  *
93
289
  * @dependency zn-button
94
290
  * @dependency zn-button-group
291
+ * @dependency zn-dropdown
95
292
  * @dependency zn-icon
96
293
  * @dependency zn-file
294
+ * @dependency zn-menu
295
+ * @dependency zn-menu-item
97
296
  * @dependency zn-slash-menu
98
297
  *
99
298
  * @event zn-input - Emitted on each keystroke while editing a block.
100
299
  * @event zn-change - Emitted when a block edit is committed and the value changes.
101
300
  *
102
301
  * @csspart base - The component's base wrapper.
103
- * @csspart toolbar - The always-visible block-insert toolbar.
302
+ * @csspart toolbar - The always-visible block-insert and inline-formatting toolbar.
104
303
  * @csspart raw-toggle - The button that switches between the block view and the raw source view.
105
304
  * @csspart block - A rendered block wrapper.
106
305
  * @csspart rendered - The rendered remarkd output of a block.
306
+ * @csspart conditional - A labelled wrapper for an ifdef/ifndef/ifeval/iftrue/iffalse/ifempty/ifnempty range.
307
+ * @csspart variable - A chip rendered for a document attribute, title, or bracket line.
107
308
  * @csspart input - The textarea shown while editing a block.
108
309
  * @csspart raw - The full-document textarea shown in raw source mode.
109
310
  * @csspart slash-menu - The `zn-slash-menu` opened by typing "/" in an empty block.
@@ -116,6 +317,9 @@ const SLASH_ITEMS: SlashMenuItem[] = BLOCK_TYPES.map(item => item.image || item.
116
317
  export default class ZnRemarkdEditor extends ZincElement implements ZincFormControl {
117
318
  static styles: CSSResultGroup = unsafeCSS(styles);
118
319
  static dependencies = {
320
+ 'zn-dropdown': ZnDropdown,
321
+ 'zn-menu': ZnMenu,
322
+ 'zn-menu-item': ZnMenuItem,
119
323
  'zn-slash-menu': ZnSlashMenu
120
324
  };
121
325
 
@@ -133,6 +337,11 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
133
337
  onSelect: item => this.handleSlashSelect(item)
134
338
  });
135
339
 
340
+ private readonly toolbarOverflow = new ToolbarOverflowController(this, {
341
+ groups: () => [...(this.shadowRoot?.querySelectorAll<HTMLElement>('.toolbar__group') ?? [])],
342
+ container: () => this.shadowRoot?.querySelector<HTMLElement>('.remarkd-editor__toolbar'),
343
+ });
344
+
136
345
  private editingDraft = '';
137
346
  private includeRequest: Promise<IncludeOption[]> | null = null;
138
347
  private rawEntryValue = '';
@@ -258,6 +467,13 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
258
467
  if (this.hasIncludeBlock()) void this.loadIncludeOptions();
259
468
  }
260
469
 
470
+ protected updated(changedProperties: PropertyValues<this>) {
471
+ super.updated(changedProperties);
472
+ // Scoped to the parser-rendered branch only — see the class comment on the rendered div.
473
+ this.shadowRoot?.querySelectorAll('.remarkd-editor__rendered--parsed')
474
+ .forEach(rendered => this.markVariables(rendered));
475
+ }
476
+
261
477
  disconnectedCallback() {
262
478
  super.disconnectedCallback();
263
479
  this.cancelDrag();
@@ -283,13 +499,14 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
283
499
 
284
500
  /**
285
501
  * Splits remarkd source into blocks on blank lines, keeping fenced /
286
- * delimited containers (``` ==== !!!! .... ----) as single blocks.
502
+ * delimited containers (``` ==== !!!! .... ---- ____ **** ////) as single blocks.
287
503
  */
288
504
  private splitBlocks(source: string): string[] {
289
505
  const lines = source.replace(/\r\n/g, '\n').split('\n');
290
506
  const blocks: string[] = [];
291
507
  let current: string[] = [];
292
508
  let fence: string | null = null;
509
+ let depth = 0;
293
510
 
294
511
  const push = () => {
295
512
  const text = current.join('\n').trim();
@@ -310,6 +527,18 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
310
527
  fence = marker;
311
528
  continue;
312
529
  }
530
+ if (depth > 0) {
531
+ current.push(line);
532
+ if (CONDITIONAL_OPEN.test(trimmed) || IFEVAL_OPEN.test(trimmed)) depth++;
533
+ else if (CONDITIONAL_END.test(trimmed) && --depth === 0) push();
534
+ continue;
535
+ }
536
+ if (CONDITIONAL_OPEN.test(trimmed) || IFEVAL_OPEN.test(trimmed)) {
537
+ push();
538
+ current.push(line);
539
+ depth = 1;
540
+ continue;
541
+ }
313
542
  if (INCLUDE_LINE.test(trimmed)) {
314
543
  push();
315
544
  current.push(line);
@@ -327,14 +556,18 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
327
556
  }
328
557
 
329
558
  private fenceMarker(line: string): string | null {
559
+ if (TABLE_FENCE_LINE.test(line)) return TABLE_FENCE;
330
560
  const backticks = /^`{3,}/.exec(line);
331
561
  if (backticks) return backticks[0];
332
- if (/^(={4,}|\.{4,}|-{4,}|!{4,})$/.test(line)) return line;
562
+ // `_{4,}`/`*{4,}`/`/{4,}` are Quote/Verse/Sidebar/Comment's delimiters — none collide with
563
+ // the 3-character Underline mark (`___`) or Divider (`---`), both below this floor.
564
+ if (/^(={4,}|\.{4,}|-{4,}|!{4,}|_{4,}|\*{4,}|\/{4,})$/.test(line)) return line;
333
565
  if (/^!!\S+!!$/.test(line)) return '!!!!';
334
566
  return null;
335
567
  }
336
568
 
337
569
  private closesFence(line: string, fence: string): boolean {
570
+ if (fence === TABLE_FENCE) return TABLE_FENCE_LINE.test(line);
338
571
  const char = fence[0];
339
572
  let count = 0;
340
573
  while (count < line.length && line[count] === char) count++;
@@ -444,14 +677,14 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
444
677
  this.updateBlocks(blocks);
445
678
  }
446
679
 
447
- private startEdit(index: number, draft?: string, align: 'nearest' | 'center' = 'nearest') {
680
+ private startEdit(index: number, draft?: string, align: 'nearest' | 'center' = 'nearest', caretOffset?: number) {
448
681
  if (this.disabled || this.readonly) return;
449
682
  this.editingDraft = draft ?? this.blocks[index] ?? '';
450
683
  this.editingIndex = index;
451
684
  this.imageEdit = null;
452
685
  this.editShell = this.computeEditShell(this.editingDraft);
453
686
  this.slashController.close();
454
- void this.focusInput(align);
687
+ void this.focusInput(align, caretOffset);
455
688
  }
456
689
 
457
690
  private updateImageEdit(patch: Partial<ImageBlockData>) {
@@ -511,7 +744,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
511
744
  return '';
512
745
  }
513
746
 
514
- private async focusInput(align: 'nearest' | 'center' = 'nearest') {
747
+ private async focusInput(align: 'nearest' | 'center' = 'nearest', caretOffset?: number) {
515
748
  await this.updateComplete;
516
749
  const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
517
750
  if (input) {
@@ -521,7 +754,12 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
521
754
  // preventScroll because the browser's own focus scroll walks every scrollable
522
755
  // ancestor — including the surrounding page panel. reveal() stays in the editor.
523
756
  input.focus({preventScroll: true});
524
- input.setSelectionRange(input.value.length, input.value.length);
757
+ // Mirrors the slash menu's own clamp (slash-menu-controller's replace()) so a toolbar
758
+ // insert lands the caret in the same place a "/" insert of the same action would.
759
+ const caret = caretOffset === undefined
760
+ ? input.value.length
761
+ : Math.min(Math.max(caretOffset, 0), input.value.length);
762
+ input.setSelectionRange(caret, caret);
525
763
  this.reveal(input, align);
526
764
  }
527
765
  this.suppressBlurCommit = false;
@@ -598,12 +836,12 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
598
836
  }
599
837
 
600
838
  /** Inserts a draft block — committed (or dropped, if left empty) on blur. */
601
- private insertDraftBlock(index: number, prefill = '') {
839
+ private insertDraftBlock(index: number, prefill = '', caretOffset?: number) {
602
840
  if (this.disabled || this.readonly) return;
603
841
  const blocks = [...this.blocks];
604
842
  blocks.splice(index, 0, '');
605
843
  this.blocks = blocks;
606
- this.startEdit(index, prefill, 'center');
844
+ this.startEdit(index, prefill, 'center', caretOffset);
607
845
  }
608
846
 
609
847
  /**
@@ -992,6 +1230,65 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
992
1230
  input.style.height = `${input.scrollHeight}px`;
993
1231
  }
994
1232
 
1233
+ /**
1234
+ * Applies an inline mark to the open block's textarea: wraps the selection, toggles the
1235
+ * mark off when it is already wrapped, or inserts a selected placeholder when there is
1236
+ * no selection. Goes through `editingDraft` so `zn-input` still fires.
1237
+ */
1238
+ private applyInline(mark: InlineMark) {
1239
+ const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
1240
+ if (!input) return;
1241
+
1242
+ const after = mark.after ?? mark.before;
1243
+ const {selectionStart: start, selectionEnd: end, value} = input;
1244
+ const selected = value.slice(start, end);
1245
+
1246
+ // A repeated-character mark nests inside its longer sibling (`~` within `~~`), so an
1247
+ // exact run is required — a prefix match would strip one character from each side. Gated
1248
+ // on symmetric marks only: asymmetric marks like Link (`[` … `](https://)`) share a
1249
+ // trailing `)` that would otherwise misfire this guard on unrelated marks.
1250
+ const symmetric = after === mark.before;
1251
+ const repeated = symmetric && mark.before.length > 0
1252
+ && mark.before === mark.before[0].repeat(mark.before.length);
1253
+ const beforeMatches = value.slice(start - mark.before.length, start) === mark.before;
1254
+
1255
+ // Link/Tooltip's closer carries a real URL or explanation, not the placeholder text, so
1256
+ // it is matched by pattern; its length is whatever the pattern actually consumed, not
1257
+ // `after.length`, since a real payload is rarely the same length as the placeholder.
1258
+ const closer = symmetric ? null : closerPattern(after);
1259
+ const closerMatch = closer?.exec(value.slice(end)) ?? null;
1260
+ const afterLength = closerMatch ? closerMatch[0].length : after.length;
1261
+ const afterMatches = closer ? !!closerMatch : value.slice(end, end + after.length) === after;
1262
+
1263
+ const outerBefore = value[start - mark.before.length - 1];
1264
+ const outerAfter = value[end + afterLength];
1265
+ const wrapped = beforeMatches && afterMatches
1266
+ && !(repeated && (outerBefore === mark.before[0] || outerAfter === after[after.length - 1]));
1267
+
1268
+ let next: string;
1269
+ let caret: [number, number];
1270
+ if (wrapped) {
1271
+ next = value.slice(0, start - mark.before.length) + selected + value.slice(end + afterLength);
1272
+ caret = [start - mark.before.length, start - mark.before.length + selected.length];
1273
+ } else if (closer && beforeMatches) {
1274
+ // A real opener with no matching closer pattern after it — decline to wrap rather than
1275
+ // nesting a second opener in front of what may already be one.
1276
+ return;
1277
+ } else {
1278
+ const body = selected || mark.placeholder || '';
1279
+ next = value.slice(0, start) + mark.before + body + after + value.slice(end);
1280
+ caret = [start + mark.before.length, start + mark.before.length + body.length];
1281
+ }
1282
+
1283
+ this.editingDraft = next;
1284
+ input.value = next;
1285
+ input.setSelectionRange(caret[0], caret[1]);
1286
+ this.editShell = this.computeEditShell(next);
1287
+ this.autosize(input);
1288
+ input.focus({preventScroll: true});
1289
+ this.emit('zn-input');
1290
+ }
1291
+
995
1292
  private toggleRawMode = () => {
996
1293
  if (this.rawMode) {
997
1294
  this.commitRaw();
@@ -1047,15 +1344,15 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1047
1344
  this.emit('zn-change');
1048
1345
  };
1049
1346
 
1050
- private handleToolbarInsert(item: BlockType) {
1347
+ private handleToolbarInsert(action: EditorAction) {
1051
1348
  if (this.editingIndex !== null) this.suppressBlurCommit = true;
1052
1349
  const index = this.editingIndex !== null ? this.commitEdit() : this.blocks.length;
1053
- if (item.image) {
1350
+ if (action.opens === 'image') {
1054
1351
  this.pickImage(index);
1055
- } else if (item.include) {
1352
+ } else if (action.opens === 'include') {
1056
1353
  this.pickInclude(index);
1057
1354
  } else {
1058
- this.insertDraftBlock(index, item.prefix ?? '');
1355
+ this.insertDraftBlock(index, action.prefix ?? '', action.caretOffset);
1059
1356
  }
1060
1357
  }
1061
1358
 
@@ -1152,6 +1449,89 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1152
1449
  </div>`;
1153
1450
  }
1154
1451
 
1452
+ /** A metadata-only block parses to nothing, so show each of its lines as a chip instead. */
1453
+ private renderMetaChips(lines: MetaLine[]) {
1454
+ return html`
1455
+ <div class="remarkd-editor__meta">
1456
+ ${lines.map(line => this.renderMetaChip(line))}
1457
+ </div>`;
1458
+ }
1459
+
1460
+ /**
1461
+ * One shared chip treatment for every metadata line kind — each kind just supplies an
1462
+ * icon, a name, and an optional value/state, display-only in all three cases.
1463
+ */
1464
+ private renderMetaChip(line: MetaLine) {
1465
+ const icon = line.kind === 'title' ? 'captions@lu' : line.kind === 'bracket' ? 'brackets@lu' : 'braces@lu';
1466
+ // Names are the code-like part (an attribute's key, a bracket line's raw text); a title
1467
+ // has none, its text is the value — so it renders in plain prose type, not monospace.
1468
+ const name = line.kind === 'attribute' ? line.name : line.kind === 'bracket' ? line.text : '';
1469
+ const value = line.kind === 'attribute' ? line.value : line.kind === 'title' ? line.text : '';
1470
+ const attrState = line.kind === 'attribute' ? (line.unset ? 'unset' : (value ? '' : 'set')) : '';
1471
+ return html`
1472
+ <div part="variable" class="remarkd-editor__variable">
1473
+ <zn-icon src=${icon} size="16"></zn-icon>
1474
+ ${name ? html`<span class="remarkd-editor__variable-name">${name}</span>` : ''}
1475
+ ${attrState
1476
+ ? html`<span class="remarkd-editor__variable-state">${attrState}</span>`
1477
+ : value
1478
+ ? html`<span class="remarkd-editor__variable-value">${value}</span>`
1479
+ : ''}
1480
+ </div>`;
1481
+ }
1482
+
1483
+ /**
1484
+ * Wraps `{name}` references so they read as variables rather than literal text. Walks text
1485
+ * nodes and skips code, so a brace in a sample stays a brace, and no regex touches markup.
1486
+ */
1487
+ private markVariables(root: Element) {
1488
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1489
+ const targets: Text[] = [];
1490
+ while (walker.nextNode()) {
1491
+ const node = walker.currentNode as Text;
1492
+ if (node.parentElement?.closest('code, pre')) continue;
1493
+ // Lit's unsafeHTML part only touches the DOM when the parsed string changes, so
1494
+ // updated() can re-run over spans this pass already produced — skip them, or a
1495
+ // repeat render nests another wrapper inside the last one.
1496
+ if (node.parentElement?.classList.contains('remarkd-editor__var')) continue;
1497
+ if (/\{[A-Za-z0-9_-]+}/.test(node.data)) targets.push(node);
1498
+ }
1499
+
1500
+ for (const node of targets) {
1501
+ const fragment = document.createDocumentFragment();
1502
+ let index = 0;
1503
+ for (const match of node.data.matchAll(/\{([A-Za-z0-9_-]+)}/g)) {
1504
+ const at = match.index;
1505
+ if (at > index) fragment.append(node.data.slice(index, at));
1506
+ const token = document.createElement('span');
1507
+ token.className = 'remarkd-editor__var';
1508
+ token.textContent = match[0];
1509
+ fragment.append(token);
1510
+ index = at + match[0].length;
1511
+ }
1512
+ if (index < node.data.length) fragment.append(node.data.slice(index));
1513
+ node.replaceWith(fragment);
1514
+ }
1515
+ }
1516
+
1517
+ /**
1518
+ * A conditional range as a labelled wrapper. The inner content is parsed without the
1519
+ * directive lines: evaluating instead would blank the block whenever the flag is not
1520
+ * defined in this same block, hiding the author's content while they edit it. It also
1521
+ * keeps both halves of an if/else idiom visible — with evaluation you could never see
1522
+ * the `ifndef` branch while the `ifdef` condition held. Nesting is handled by
1523
+ * `splitConditionalParts`/`renderConditionalWrapper` below, recursively.
1524
+ */
1525
+ private renderConditional(block: string): TemplateResult | null {
1526
+ const lines = block.split('\n');
1527
+ const open = lines[0]?.trimEnd() ?? '';
1528
+ if (!CONDITIONAL_OPEN.test(open) && !IFEVAL_OPEN.test(open)) return null;
1529
+
1530
+ const end = findConditionalEnd(lines, 1);
1531
+ const interior = lines.slice(1, end === -1 ? undefined : end);
1532
+ return renderConditionalWrapper(conditionalLabel(open), splitConditionalParts(interior));
1533
+ }
1534
+
1155
1535
  private renderBlock(block: string, index: number) {
1156
1536
  if (this.editingIndex === index) {
1157
1537
  if (this.imageEdit) {
@@ -1176,6 +1556,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1176
1556
  }
1177
1557
 
1178
1558
  const include = this.parseIncludeBlock(block);
1559
+ const conditional = include ? null : (this.renderConditional(block) ?? renderInlineConditional(block));
1560
+ const meta = include || conditional ? null : parseMetaBlock(block.trim());
1179
1561
  return html`
1180
1562
  <div part="block"
1181
1563
  class=${classMap({
@@ -1199,13 +1581,63 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1199
1581
  type="button" icon-button="small" plain icon="x@lu" icon-size="16" color="error"
1200
1582
  tooltip="Delete block"
1201
1583
  @click=${() => this.deleteBlock(index)}></zn-button>`}
1202
- <div part="rendered" class="remarkd-editor__rendered remarkd-rendered"
1584
+ <div part="rendered"
1585
+ class=${classMap({
1586
+ 'remarkd-editor__rendered': true,
1587
+ 'remarkd-rendered': true,
1588
+ // Only the parser-rendered branch is safe for markVariables to mutate — the
1589
+ // include/meta branches are plain Lit templates whose ChildPart bindings assume
1590
+ // their next sibling stays a Text node, so it must not touch those. The
1591
+ // conditional branch is also a Lit template (its label is a string binding) —
1592
+ // its own inner body div carries the marker instead, see renderConditional.
1593
+ 'remarkd-editor__rendered--parsed': !include && !conditional && !meta,
1594
+ })}
1203
1595
  @click=${(e: MouseEvent) => this.handleRenderedClick(e, index)}>${
1204
- include ? this.renderIncludeChip(include) : unsafeHTML(remarkdParse(block))}
1596
+ include ? this.renderIncludeChip(include)
1597
+ : conditional ? conditional
1598
+ : meta ? this.renderMetaChips(meta)
1599
+ : unsafeHTML(safeParse(block))}
1205
1600
  </div>
1206
1601
  </div>`;
1207
1602
  }
1208
1603
 
1604
+ /** An inline action needs an open block to apply its mark to. */
1605
+ private isActionDisabled(action: EditorAction): boolean {
1606
+ return !!action.inline && this.editingIndex === null;
1607
+ }
1608
+
1609
+ /** Routes a toolbar/menu action to the inline or block insert path — the one place both
1610
+ * `renderAction` and `renderMenuAction` call, so the bar and the overflow menu cannot
1611
+ * drift out of sync on what a given action actually does. */
1612
+ private activateAction(action: EditorAction) {
1613
+ if (action.inline) {
1614
+ this.applyInline(action.inline);
1615
+ } else {
1616
+ this.handleToolbarInsert(action);
1617
+ }
1618
+ }
1619
+
1620
+ /** A single toolbar action button, shared by the toolbar bar and the overflow menu. */
1621
+ private renderAction(action: EditorAction) {
1622
+ return html`
1623
+ <zn-button type="button" icon-button plain icon=${action.icon} icon-size="18"
1624
+ tooltip=${action.label}
1625
+ ?disabled=${this.isActionDisabled(action)}
1626
+ @mousedown=${(e: MouseEvent) => e.preventDefault()}
1627
+ @click=${() => this.activateAction(action)}></zn-button>`;
1628
+ }
1629
+
1630
+ /** The same action, rendered as a menu item for the overflow menu. */
1631
+ private renderMenuAction(action: EditorAction) {
1632
+ return html`
1633
+ <zn-menu-item
1634
+ ?disabled=${this.isActionDisabled(action)}
1635
+ @click=${() => this.activateAction(action)}>
1636
+ <zn-icon slot="prefix" src=${action.icon} size="18"></zn-icon>
1637
+ ${action.label}
1638
+ </zn-menu-item>`;
1639
+ }
1640
+
1209
1641
  render() {
1210
1642
  const editable = !this.disabled && !this.readonly;
1211
1643
  return html`
@@ -1219,12 +1651,40 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1219
1651
  @drop=${this.handleDrop}>
1220
1652
  ${editable ? html`
1221
1653
  <div part="toolbar" class="remarkd-editor__toolbar">
1222
- ${this.rawMode ? '' : BLOCK_TYPES
1223
- .filter(item => !item.include || !!this.includeUrl)
1224
- .map(item => html`
1225
- <zn-button type="button" icon-button plain icon=${item.icon} icon-size="18"
1226
- tooltip=${item.label}
1227
- @click=${() => this.handleToolbarInsert(item)}></zn-button>`)}
1654
+ ${this.rawMode ? '' : (() => {
1655
+ const groups = ACTION_GROUPS
1656
+ .map(group => ({
1657
+ group,
1658
+ actions: EDITOR_ACTIONS.filter(action => action.group === group.id
1659
+ && (action.opens !== 'include' || !!this.includeUrl)),
1660
+ }))
1661
+ .filter(entry => entry.actions.length);
1662
+ const visible = this.toolbarOverflow.visibleCount;
1663
+ const overflowed = groups.slice(visible);
1664
+
1665
+ return html`
1666
+ ${groups.map(entry => html`
1667
+ <div class="toolbar__group" data-group=${entry.group.id}>
1668
+ ${entry.actions.map(action => this.renderAction(action))}
1669
+ </div>`)}
1670
+ ${overflowed.length ? html`
1671
+ <zn-dropdown class="remarkd-editor__toolbar-more" placement="bottom-end"
1672
+ @zn-show=${(e: Event) => {
1673
+ if (e.target !== e.currentTarget) return;
1674
+ this.suppressBlurCommit = true;
1675
+ }}
1676
+ @zn-hide=${(e: Event) => {
1677
+ if (e.target !== e.currentTarget) return;
1678
+ this.suppressBlurCommit = false;
1679
+ }}>
1680
+ <zn-button slot="trigger" type="button" icon-button plain icon="ellipsis@lu"
1681
+ icon-size="18" tooltip="More"
1682
+ @mousedown=${(e: MouseEvent) => e.preventDefault()}></zn-button>
1683
+ <zn-menu>
1684
+ ${overflowed.flatMap(entry => entry.actions).map(action => this.renderMenuAction(action))}
1685
+ </zn-menu>
1686
+ </zn-dropdown>` : ''}`;
1687
+ })()}
1228
1688
  ${this.allowRaw ? html`
1229
1689
  <zn-button part="raw-toggle" class="remarkd-editor__raw-toggle"
1230
1690
  type="button" icon-button icon="code-xml@lu" icon-size="18"