@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.
- package/dist/custom-elements.json +261 -82
- package/dist/vscode.html-custom-data.json +13 -13
- package/dist/web-types.json +25 -25
- package/dist/zn.d.ts +128 -6
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +469 -393
- package/docs/pages/components/remarkd-editor.md +59 -0
- package/package.json +1 -1
- package/scss/_root.scss +121 -8
- package/scss/shared/_base.scss +10 -4
- package/scss/shared/_button-mixin.scss +29 -0
- package/scss/shared/_button.scss +3 -33
- package/scss/themes/_aura.scss +87 -8
- package/scss/themes/_dark.scss +14 -2
- package/scss/themes/_light.scss +4 -3
- package/scss/themes/_modes.scss +32 -0
- package/src/components/animated-button/animated-button.component.ts +2 -1
- package/src/components/animated-button/animated-button.scss +1 -1
- package/src/components/bulk-actions/bulk-actions.component.ts +2 -1
- package/src/components/bulk-actions/bulk-actions.scss +0 -1
- package/src/components/button/button.component.ts +6 -1
- package/src/components/button/button.scss +1 -1
- package/src/components/chart/chart.component.ts +4 -0
- package/src/components/checkbox/checkbox.component.ts +3 -1
- package/src/components/checkbox/checkbox.scss +0 -2
- package/src/components/checkbox-group/checkbox-group.component.ts +2 -1
- package/src/components/checkbox-group/checkbox-group.scss +0 -1
- package/src/components/data-table/data-table.component.ts +3 -1
- package/src/components/data-table/data-table.scss +0 -1
- package/src/components/datepicker/datepicker.component.ts +2 -1
- package/src/components/datepicker/datepicker.scss +0 -1
- package/src/components/editor/modules/toolbar/toolbar.component.ts +3 -1
- package/src/components/editor/modules/toolbar/toolbar.scss +0 -1
- package/src/components/file/file.component.ts +2 -1
- package/src/components/file/file.scss +0 -1
- package/src/components/form-group/form-group.component.ts +2 -1
- package/src/components/form-group/form-group.scss +0 -1
- package/src/components/icon-picker/icon-picker.component.ts +8 -3
- package/src/components/icon-picker/icon-picker.scss +0 -1
- package/src/components/inline-edit/inline-edit.component.ts +2 -1
- package/src/components/inline-edit/inline-edit.scss +0 -1
- package/src/components/input/input.component.ts +2 -1
- package/src/components/input/input.scss +0 -1
- package/src/components/input-group/input-group.component.ts +2 -1
- package/src/components/input-group/input-group.scss +0 -1
- package/src/components/menu/menu.component.ts +4 -1
- package/src/components/menu/menu.scss +9 -1
- package/src/components/page/page.scss +19 -5
- package/src/components/priority-list/priority-list.component.ts +2 -1
- package/src/components/priority-list/priority-list.scss +0 -1
- package/src/components/query-builder/query-builder.component.ts +2 -1
- package/src/components/query-builder/query-builder.scss +0 -1
- package/src/components/radio/radio.component.ts +3 -1
- package/src/components/radio/radio.scss +0 -2
- package/src/components/radio-group/radio-group.component.ts +2 -1
- package/src/components/radio-group/radio-group.scss +0 -1
- package/src/components/rating/rating.component.ts +2 -1
- package/src/components/rating/rating.scss +0 -1
- package/src/components/remarkd-editor/actions.ts +155 -0
- package/src/components/remarkd-editor/feature-keys.ts +17 -0
- package/src/components/remarkd-editor/remarkd-editor.component.ts +509 -49
- package/src/components/remarkd-editor/remarkd-editor.scss +68 -1
- package/src/components/remarkd-editor/remarkd-editor.test.ts +677 -9
- package/src/components/schedule-builder/schedule-builder.component.ts +2 -1
- package/src/components/schedule-builder/schedule-builder.scss +0 -1
- package/src/components/select/select.component.ts +2 -1
- package/src/components/select/select.scss +0 -1
- package/src/components/slash-menu/slash-menu.scss +2 -0
- package/src/components/tabs/tabs.component.ts +3 -1
- package/src/components/tabs/tabs.scss +0 -1
- package/src/components/textarea/textarea.component.ts +2 -1
- package/src/components/textarea/textarea.scss +0 -1
- package/src/components/tile/tile.component.ts +33 -20
- package/src/components/tile/tile.scss +2 -0
- package/src/components/tile/tile.test.ts +64 -3
- package/src/components/toggle/toggle.component.ts +2 -1
- package/src/components/toggle/toggle.scss +0 -1
- package/src/components/translations/translations.component.ts +2 -1
- package/src/components/translations/translations.scss +0 -1
- package/src/internal/theme.ts +37 -12
- package/src/internal/toolbar-overflow.ts +100 -0
- 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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 => ({'&': '&', '<': '<', '>': '>'}[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
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
1350
|
+
if (action.opens === 'image') {
|
|
1054
1351
|
this.pickImage(index);
|
|
1055
|
-
} else if (
|
|
1352
|
+
} else if (action.opens === 'include') {
|
|
1056
1353
|
this.pickInclude(index);
|
|
1057
1354
|
} else {
|
|
1058
|
-
this.insertDraftBlock(index,
|
|
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"
|
|
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)
|
|
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 ? '' :
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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"
|