@aquera/nile-elements 1.8.5 → 1.8.6

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 (63) hide show
  1. package/README.md +4 -0
  2. package/dist/index.cjs.js +1 -1
  3. package/dist/index.esm.js +1 -1
  4. package/dist/index.js +848 -299
  5. package/dist/nile-markdown/index.cjs.js +2 -0
  6. package/dist/nile-markdown/index.cjs.js.map +1 -0
  7. package/dist/nile-markdown/index.esm.js +1 -0
  8. package/dist/nile-markdown/nile-markdown.cjs.js +30 -0
  9. package/dist/nile-markdown/nile-markdown.cjs.js.map +1 -0
  10. package/dist/nile-markdown/nile-markdown.css.cjs.js +2 -0
  11. package/dist/nile-markdown/nile-markdown.css.cjs.js.map +1 -0
  12. package/dist/nile-markdown/nile-markdown.css.esm.js +152 -0
  13. package/dist/nile-markdown/nile-markdown.esm.js +3 -0
  14. package/dist/nile-markdown-editor/index.cjs.js +2 -0
  15. package/dist/nile-markdown-editor/index.cjs.js.map +1 -0
  16. package/dist/nile-markdown-editor/index.esm.js +1 -0
  17. package/dist/nile-markdown-editor/nile-markdown-editor.cjs.js +2 -0
  18. package/dist/nile-markdown-editor/nile-markdown-editor.cjs.js.map +1 -0
  19. package/dist/nile-markdown-editor/nile-markdown-editor.css.cjs.js +2 -0
  20. package/dist/nile-markdown-editor/nile-markdown-editor.css.cjs.js.map +1 -0
  21. package/dist/nile-markdown-editor/nile-markdown-editor.css.esm.js +255 -0
  22. package/dist/nile-markdown-editor/nile-markdown-editor.esm.js +143 -0
  23. package/dist/src/index.d.ts +2 -0
  24. package/dist/src/index.js +2 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/nile-markdown/index.d.ts +1 -0
  27. package/dist/src/nile-markdown/index.js +2 -0
  28. package/dist/src/nile-markdown/index.js.map +1 -0
  29. package/dist/src/nile-markdown/nile-markdown.css.d.ts +10 -0
  30. package/dist/src/nile-markdown/nile-markdown.css.js +163 -0
  31. package/dist/src/nile-markdown/nile-markdown.css.js.map +1 -0
  32. package/dist/src/nile-markdown/nile-markdown.d.ts +91 -0
  33. package/dist/src/nile-markdown/nile-markdown.js +167 -0
  34. package/dist/src/nile-markdown/nile-markdown.js.map +1 -0
  35. package/dist/src/nile-markdown/nile-markdown.test.d.ts +1 -0
  36. package/dist/src/nile-markdown/nile-markdown.test.js +192 -0
  37. package/dist/src/nile-markdown/nile-markdown.test.js.map +1 -0
  38. package/dist/src/nile-markdown-editor/index.d.ts +1 -0
  39. package/dist/src/nile-markdown-editor/index.js +2 -0
  40. package/dist/src/nile-markdown-editor/index.js.map +1 -0
  41. package/dist/src/nile-markdown-editor/nile-markdown-editor.css.d.ts +10 -0
  42. package/dist/src/nile-markdown-editor/nile-markdown-editor.css.js +266 -0
  43. package/dist/src/nile-markdown-editor/nile-markdown-editor.css.js.map +1 -0
  44. package/dist/src/nile-markdown-editor/nile-markdown-editor.d.ts +121 -0
  45. package/dist/src/nile-markdown-editor/nile-markdown-editor.js +615 -0
  46. package/dist/src/nile-markdown-editor/nile-markdown-editor.js.map +1 -0
  47. package/dist/src/nile-markdown-editor/nile-markdown-editor.test.d.ts +1 -0
  48. package/dist/src/nile-markdown-editor/nile-markdown-editor.test.js +268 -0
  49. package/dist/src/nile-markdown-editor/nile-markdown-editor.test.js.map +1 -0
  50. package/dist/src/version.js +1 -1
  51. package/dist/src/version.js.map +1 -1
  52. package/dist/tsconfig.tsbuildinfo +1 -1
  53. package/package.json +2 -1
  54. package/src/index.ts +3 -1
  55. package/src/nile-markdown/index.ts +1 -0
  56. package/src/nile-markdown/nile-markdown.css.ts +164 -0
  57. package/src/nile-markdown/nile-markdown.test.ts +252 -0
  58. package/src/nile-markdown/nile-markdown.ts +179 -0
  59. package/src/nile-markdown-editor/index.ts +1 -0
  60. package/src/nile-markdown-editor/nile-markdown-editor.css.ts +267 -0
  61. package/src/nile-markdown-editor/nile-markdown-editor.test.ts +402 -0
  62. package/src/nile-markdown-editor/nile-markdown-editor.ts +710 -0
  63. package/vscode-html-custom-data.json +82 -0
@@ -0,0 +1,710 @@
1
+ /**
2
+ * Copyright Aquera Inc 2023
3
+ *
4
+ * This source code is licensed under the BSD-3-Clause license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import { html, nothing } from 'lit';
9
+ import { customElement, property, query, state } from 'lit/decorators.js';
10
+ import { classMap } from 'lit/directives/class-map.js';
11
+ import { styles } from './nile-markdown-editor.css';
12
+ import NileElement from '../internal/nile-element';
13
+ import '../nile-markdown/nile-markdown';
14
+ import '../nile-icon';
15
+ import '../nile-button-toggle-group/nile-button-toggle-group';
16
+ import '../nile-button-toggle/nile-button-toggle';
17
+ import '../nile-dropdown/nile-dropdown';
18
+ import '../nile-menu/nile-menu';
19
+ import '../nile-menu-item/nile-menu-item';
20
+ import type { CSSResultGroup, TemplateResult } from 'lit';
21
+
22
+ export type MarkdownEditorMode = 'write' | 'preview' | 'split';
23
+
24
+ /** A toolbar action that wraps the selection with markdown delimiters. */
25
+ interface WrapAction {
26
+ name: string;
27
+ title: string;
28
+ kind: 'wrap';
29
+ prefix: string;
30
+ suffix: string;
31
+ placeholder: string;
32
+ icon?: string;
33
+ glyph?: string;
34
+ }
35
+
36
+ /** A toolbar action that toggles a prefix on each selected line. */
37
+ interface LineAction {
38
+ name: string;
39
+ title: string;
40
+ kind: 'line';
41
+ prefix: string;
42
+ numbered?: boolean;
43
+ icon?: string;
44
+ glyph?: string;
45
+ }
46
+
47
+ interface LinkAction {
48
+ name: string;
49
+ title: string;
50
+ kind: 'link';
51
+ icon?: string;
52
+ glyph?: string;
53
+ }
54
+
55
+ /** A single option inside a {@link MenuAction} dropdown. */
56
+ interface MenuItem {
57
+ label: string;
58
+ /** The line prefix applied when this item is chosen, e.g. `'## '`. */
59
+ prefix: string;
60
+ /** Optional nile-glyph name shown beside the label. */
61
+ glyph?: string;
62
+ }
63
+
64
+ /** A toolbar action that opens a dropdown of line-prefix choices. */
65
+ interface MenuAction {
66
+ name: string;
67
+ title: string;
68
+ kind: 'menu';
69
+ items: MenuItem[];
70
+ icon?: string;
71
+ glyph?: string;
72
+ }
73
+
74
+ type ToolbarAction = WrapAction | LineAction | LinkAction | MenuAction;
75
+
76
+ /** Toolbar actions, grouped — groups are separated by vertical dividers. */
77
+ const TOOLBAR_GROUPS: ToolbarAction[][] = [
78
+ [
79
+ {
80
+ name: 'heading',
81
+ title: 'Heading',
82
+ kind: 'menu',
83
+ glyph: 'ng-heading',
84
+ items: [
85
+ { label: 'Heading 1', prefix: '# ', glyph: 'ng-heading-1' },
86
+ { label: 'Heading 2', prefix: '## ', glyph: 'ng-heading-2' },
87
+ { label: 'Heading 3', prefix: '### ', glyph: 'ng-heading-3' },
88
+ { label: 'Heading 4', prefix: '#### ', glyph: 'ng-heading-4' },
89
+ { label: 'Heading 5', prefix: '##### ', glyph: 'ng-heading-5' },
90
+ { label: 'Heading 6', prefix: '###### ', glyph: 'ng-heading-6' },
91
+ ],
92
+ },
93
+ {
94
+ name: 'bold',
95
+ title: 'Bold (Ctrl+B)',
96
+ kind: 'wrap',
97
+ prefix: '**',
98
+ suffix: '**',
99
+ placeholder: 'bold text',
100
+ icon: 'format_bold',
101
+ },
102
+ {
103
+ name: 'italic',
104
+ title: 'Italic (Ctrl+I)',
105
+ kind: 'wrap',
106
+ prefix: '_',
107
+ suffix: '_',
108
+ placeholder: 'italic text',
109
+ icon: 'format_italic',
110
+ },
111
+ {
112
+ name: 'strikethrough',
113
+ title: 'Strikethrough',
114
+ kind: 'wrap',
115
+ prefix: '~~',
116
+ suffix: '~~',
117
+ placeholder: 'text',
118
+ glyph: 'ng-strikethrough',
119
+ },
120
+ ],
121
+ [
122
+ { name: 'quote', title: 'Quote', kind: 'line', prefix: '> ', glyph: 'ng-quote' },
123
+ {
124
+ name: 'code',
125
+ title: 'Code',
126
+ kind: 'wrap',
127
+ prefix: '`',
128
+ suffix: '`',
129
+ placeholder: 'code',
130
+ glyph: 'ng-code',
131
+ },
132
+ ],
133
+ [
134
+ {
135
+ name: 'ul',
136
+ title: 'Bulleted list',
137
+ kind: 'line',
138
+ prefix: '- ',
139
+ icon: 'format_list_bulleted',
140
+ },
141
+ {
142
+ name: 'ol',
143
+ title: 'Numbered list',
144
+ kind: 'line',
145
+ prefix: '1. ',
146
+ numbered: true,
147
+ icon: 'format_list_numbered',
148
+ },
149
+ ],
150
+ [{ name: 'link', title: 'Link (Ctrl+K)', kind: 'link', icon: 'link_2' }],
151
+ ];
152
+
153
+ const TOOLBAR_ACTIONS: ToolbarAction[] = TOOLBAR_GROUPS.flat();
154
+
155
+ /** Glyph shown alongside each view-mode tab label. */
156
+ const TAB_GLYPHS: Record<MarkdownEditorMode, string> = {
157
+ write: 'ng-pencil',
158
+ preview: 'ng-eye',
159
+ split: 'ng-square-split-horizontal',
160
+ };
161
+
162
+ /**
163
+ * Nile markdown editor component.
164
+ *
165
+ * @tag nile-markdown-editor
166
+ */
167
+
168
+ /**
169
+ * @summary A GitHub-style markdown editor with a formatting toolbar and a
170
+ * live preview rendered by `nile-markdown`.
171
+ * @status experimental
172
+ *
173
+ * @dependency nile-markdown
174
+ * @dependency nile-icon
175
+ * @dependency nile-glyph
176
+ * @dependency nile-button-toggle-group
177
+ * @dependency nile-button-toggle
178
+ *
179
+ * @attr {string} tools - JSON-array allowlist of toolbar tools to show, e.g.
180
+ * `tools='["bold","italic","link"]'`. Valid names: heading, bold, italic,
181
+ * strikethrough, quote, code, ul, ol, link. Empty shows all.
182
+ *
183
+ * @event nile-input - Emitted with `{ value }` on every keystroke or toolbar action.
184
+ * @event nile-change - Emitted with `{ value }` when the editor loses focus after an edit.
185
+ * @event nile-mode-change - Emitted with `{ mode }` when the write/preview/split mode changes.
186
+ *
187
+ * @csspart base - The component's base wrapper.
188
+ * @csspart header - The header containing the tabs and toolbar.
189
+ * @csspart toolbar - The formatting toolbar.
190
+ * @csspart textarea - The markdown source textarea.
191
+ * @csspart preview - The rendered preview pane.
192
+ */
193
+
194
+ @customElement('nile-markdown-editor')
195
+ export class NileMarkdownEditor extends NileElement {
196
+ static styles: CSSResultGroup = styles;
197
+
198
+ /** The markdown source. */
199
+ @property() value = '';
200
+
201
+ /** Placeholder shown when the editor is empty. */
202
+ @property() placeholder = 'Write markdown here…';
203
+
204
+ /** Disables the editor. */
205
+ @property({ type: Boolean, reflect: true }) disabled = false;
206
+
207
+ /** Makes the source read-only while still allowing mode switching. */
208
+ @property({ type: Boolean, reflect: true }) readonly = false;
209
+
210
+ /** Number of visible text rows in write mode. */
211
+ @property({ type: Number }) rows = 8;
212
+
213
+ /** Active view: `write`, `preview`, or `split`. */
214
+ @property({ reflect: true }) mode: MarkdownEditorMode = 'write';
215
+
216
+ /** Hides the formatting toolbar. */
217
+ @property({ type: Boolean, attribute: 'hide-toolbar' }) hideToolbar = false;
218
+
219
+ /**
220
+ * Allowlist of toolbar tool names to show. Accepts a JSON array via the
221
+ * `tools` attribute (e.g. `tools='["bold","italic","link"]'`), a
222
+ * comma-separated string, or an array when set as a property. When empty
223
+ * (default) every tool is shown. Valid names: `heading`, `bold`, `italic`,
224
+ * `strikethrough`, `quote`, `code`, `ul`, `ol`, `link`.
225
+ */
226
+ @property({
227
+ converter: {
228
+ fromAttribute: (value: string | null) => {
229
+ if (!value) return [];
230
+ try {
231
+ const parsed = JSON.parse(value);
232
+ if (Array.isArray(parsed)) {
233
+ return parsed.map(s => String(s).trim()).filter(Boolean);
234
+ }
235
+ } catch {
236
+ // Not JSON — fall back to a comma-separated string.
237
+ }
238
+ return value.split(',').map(s => s.trim()).filter(Boolean);
239
+ },
240
+ toAttribute: (value: string[]) => JSON.stringify(value ?? []),
241
+ },
242
+ })
243
+ tools: string[] = [];
244
+
245
+ /** Parsed allowlist, or `null` when no allowlist is set (show everything). */
246
+ private get allowedTools(): Set<string> | null {
247
+ const names = (this.tools ?? []).map(s => s.trim()).filter(Boolean);
248
+ return names.length ? new Set(names) : null;
249
+ }
250
+
251
+ /** Toolbar groups after applying the allowlist; empty groups are dropped. */
252
+ private get visibleGroups(): ToolbarAction[][] {
253
+ const allowed = this.allowedTools;
254
+ if (!allowed) return TOOLBAR_GROUPS;
255
+ return TOOLBAR_GROUPS.map(group =>
256
+ group.filter(action => allowed.has(action.name))
257
+ ).filter(group => group.length > 0);
258
+ }
259
+
260
+ @query('textarea') private textarea?: HTMLTextAreaElement;
261
+
262
+ @query('.body') private bodyEl?: HTMLElement;
263
+
264
+ /**
265
+ * Fraction of the body width given to the write pane in split mode
266
+ * (the rest goes to the preview). Clamped to a sensible range while
267
+ * dragging the splitter.
268
+ */
269
+ @state() private splitRatio = 0.5;
270
+
271
+ /** Whether the split divider is currently being dragged. */
272
+ @state() private splitDragging = false;
273
+
274
+ /** Begins a pointer-driven resize of the split divider. */
275
+ private startSplitDrag = (e: PointerEvent) => {
276
+ if (this.mode !== 'split') return;
277
+ const body = this.bodyEl;
278
+ if (!body) return;
279
+ e.preventDefault();
280
+ this.splitDragging = true;
281
+
282
+ const rect = body.getBoundingClientRect();
283
+ const MIN = 0.15;
284
+ const MAX = 0.85;
285
+
286
+ const onMove = (ev: PointerEvent) => {
287
+ if (rect.width === 0) return;
288
+ const ratio = (ev.clientX - rect.left) / rect.width;
289
+ this.splitRatio = Math.min(MAX, Math.max(MIN, ratio));
290
+ };
291
+ const onUp = () => {
292
+ this.splitDragging = false;
293
+ window.removeEventListener('pointermove', onMove);
294
+ window.removeEventListener('pointerup', onUp);
295
+ };
296
+ window.addEventListener('pointermove', onMove);
297
+ window.addEventListener('pointerup', onUp);
298
+ };
299
+
300
+ /** Resets the splitter to a 50/50 layout (double-click affordance). */
301
+ private resetSplit = () => {
302
+ this.splitRatio = 0.5;
303
+ };
304
+
305
+ /** Moves focus to the textarea. */
306
+ focus(options?: FocusOptions): void {
307
+ this.textarea?.focus(options);
308
+ }
309
+
310
+ blur(): void {
311
+ this.textarea?.blur();
312
+ }
313
+
314
+ private setMode(mode: MarkdownEditorMode) {
315
+ if (this.mode === mode) return;
316
+ this.mode = mode;
317
+ this.emit('nile-mode-change', { mode }, true, true);
318
+ }
319
+
320
+ private handleInput = (e: Event) => {
321
+ this.value = (e.target as HTMLTextAreaElement).value;
322
+ this.emit('nile-input', { value: this.value }, true, true);
323
+ };
324
+
325
+ private handleChange = () => {
326
+ this.emit('nile-change', { value: this.value }, true, true);
327
+ };
328
+
329
+ private handleKeydown = (e: KeyboardEvent) => {
330
+ if (!(e.metaKey || e.ctrlKey)) return;
331
+ const key = e.key.toLowerCase();
332
+ const action = { b: 'bold', i: 'italic', k: 'link' }[
333
+ key as 'b' | 'i' | 'k'
334
+ ];
335
+ if (!action) return;
336
+ // Respect the allowlist: a hidden tool's shortcut is disabled too.
337
+ const allowed = this.allowedTools;
338
+ if (allowed && !allowed.has(action)) return;
339
+ e.preventDefault();
340
+ this.runAction(TOOLBAR_ACTIONS.find(a => a.name === action)!);
341
+ };
342
+
343
+ /** Replaces the current selection and restores focus/selection. */
344
+ private replaceSelection(
345
+ start: number,
346
+ end: number,
347
+ replacement: string,
348
+ selectStart: number,
349
+ selectEnd: number
350
+ ) {
351
+ const ta = this.textarea;
352
+ if (!ta) return;
353
+ ta.focus();
354
+ // Select the range to overwrite, then insert via the browser's editing
355
+ // pipeline so the change is recorded on the native undo/redo stack.
356
+ // Assigning `ta.value` directly would wipe that stack and break Ctrl/Cmd+Z.
357
+ ta.setSelectionRange(start, end);
358
+ const inserted = document.execCommand('insertText', false, replacement);
359
+ if (inserted) {
360
+ // execCommand dispatched a native `input` event, so `handleInput`
361
+ // already synced `value` and emitted `nile-input`; just fix selection.
362
+ ta.setSelectionRange(selectStart, selectEnd);
363
+ return;
364
+ }
365
+ // Fallback for environments without execCommand support.
366
+ ta.value = ta.value.slice(0, start) + replacement + ta.value.slice(end);
367
+ ta.setSelectionRange(selectStart, selectEnd);
368
+ this.value = ta.value;
369
+ this.emit('nile-input', { value: this.value }, true, true);
370
+ }
371
+
372
+ /** Wraps (or unwraps) the selection with prefix/suffix delimiters. */
373
+ private applyWrap(action: WrapAction) {
374
+ const ta = this.textarea;
375
+ if (!ta) return;
376
+ const { selectionStart: start, selectionEnd: end, value } = ta;
377
+ const { prefix, suffix } = action;
378
+ const selected = value.slice(start, end);
379
+
380
+ // Toggle off when the selection is already wrapped
381
+ const before = value.slice(Math.max(0, start - prefix.length), start);
382
+ const after = value.slice(end, end + suffix.length);
383
+ if (before === prefix && after === suffix) {
384
+ this.replaceSelection(
385
+ start - prefix.length,
386
+ end + suffix.length,
387
+ selected,
388
+ start - prefix.length,
389
+ end - prefix.length
390
+ );
391
+ return;
392
+ }
393
+
394
+ const content = selected || action.placeholder;
395
+ this.replaceSelection(
396
+ start,
397
+ end,
398
+ prefix + content + suffix,
399
+ start + prefix.length,
400
+ start + prefix.length + content.length
401
+ );
402
+ }
403
+
404
+ /** Toggles a markdown prefix on every line in the selection. */
405
+ private applyLinePrefix(action: LineAction) {
406
+ const ta = this.textarea;
407
+ if (!ta) return;
408
+ const { selectionStart: start, selectionEnd: end, value } = ta;
409
+
410
+ // Expand the selection to whole lines
411
+ const lineStart = value.lastIndexOf('\n', start - 1) + 1;
412
+ const lineEndIndex = value.indexOf('\n', end);
413
+ const lineEnd = lineEndIndex === -1 ? value.length : lineEndIndex;
414
+ const block = value.slice(lineStart, lineEnd);
415
+ const lines = block.split('\n');
416
+
417
+ const matcher = action.numbered ? /^\d+\.\s/ : undefined;
418
+ const hasPrefix = lines.every(line =>
419
+ matcher ? matcher.test(line) : line.startsWith(action.prefix)
420
+ );
421
+
422
+ const replaced = lines
423
+ .map((line, i) => {
424
+ if (hasPrefix) {
425
+ return matcher
426
+ ? line.replace(matcher, '')
427
+ : line.slice(action.prefix.length);
428
+ }
429
+ return action.numbered ? `${i + 1}. ${line}` : action.prefix + line;
430
+ })
431
+ .join('\n');
432
+
433
+ this.replaceSelection(
434
+ lineStart,
435
+ lineEnd,
436
+ replaced,
437
+ lineStart,
438
+ lineStart + replaced.length
439
+ );
440
+ }
441
+
442
+ /** Inserts a `[text](url)` link, selecting the URL for quick editing. */
443
+ private insertLink() {
444
+ const ta = this.textarea;
445
+ if (!ta) return;
446
+ const { selectionStart: start, selectionEnd: end, value } = ta;
447
+ const text = value.slice(start, end) || 'text';
448
+ const url = 'url';
449
+ const replacement = `[${text}](${url})`;
450
+ const urlStart = start + text.length + 3; // "[text](".length
451
+ this.replaceSelection(
452
+ start,
453
+ end,
454
+ replacement,
455
+ urlStart,
456
+ urlStart + url.length
457
+ );
458
+ }
459
+
460
+ private runAction(action: ToolbarAction) {
461
+ if (this.readonly || this.disabled) return;
462
+ if (this.mode === 'preview') this.setMode('write');
463
+ switch (action.kind) {
464
+ case 'wrap':
465
+ this.applyWrap(action);
466
+ break;
467
+ case 'line':
468
+ this.applyLinePrefix(action);
469
+ break;
470
+ case 'link':
471
+ this.insertLink();
472
+ break;
473
+ case 'menu':
474
+ // The dropdown items drive the edits via `runMenuItem`; nothing to do
475
+ // when the trigger itself is activated.
476
+ break;
477
+ }
478
+ }
479
+
480
+ /** Applies a heading-style line prefix chosen from a dropdown menu. */
481
+ private runMenuItem(action: MenuAction, item: MenuItem) {
482
+ if (this.readonly || this.disabled) return;
483
+ if (this.mode === 'preview') this.setMode('write');
484
+ this.applyLinePrefix({
485
+ name: action.name,
486
+ title: action.title,
487
+ kind: 'line',
488
+ prefix: item.prefix,
489
+ });
490
+ }
491
+
492
+ /** Renders the glyph/icon shown inside a toolbar control. */
493
+ private renderActionContent(action: ToolbarAction): TemplateResult {
494
+ if (action.icon) {
495
+ return html`<nile-icon
496
+ name=${action.icon}
497
+ size="20"
498
+ color=${this.disabled
499
+ ? 'var(--nile-colors-neutral-500, var(--ng-colors-fg-disabled-subtle))'
500
+ : 'var(--nile-colors-dark-900, var(--ng-colors-text-primary-900))'}
501
+ ></nile-icon>`;
502
+ }
503
+ // `ng-*` glyph names map to nile-glyph icons; anything else is plain text.
504
+ if (action.glyph?.startsWith('ng-')) {
505
+ return html`<nile-glyph
506
+ name=${action.glyph}
507
+ method="stroke"
508
+ color=${this.disabled
509
+ ? 'var(--nile-colors-neutral-500, var(--ng-colors-fg-disabled-subtle))'
510
+ : 'var(--nile-colors-dark-900, var(--ng-colors-text-primary-900))'}
511
+ size="20"
512
+ ></nile-glyph>`;
513
+ }
514
+ return html`<span class="toolbar__glyph--${action.name}">
515
+ ${action.glyph}
516
+ </span>`;
517
+ }
518
+
519
+ /** A standard click-to-apply toolbar button. */
520
+ private renderActionButton(action: ToolbarAction): TemplateResult {
521
+ return html`
522
+ <button
523
+ type="button"
524
+ class="toolbar__button"
525
+ title=${action.title}
526
+ aria-label=${action.title}
527
+ tabindex="-1"
528
+ @mousedown=${(e: MouseEvent) => e.preventDefault()}
529
+ @click=${() => this.runAction(action)}
530
+ >
531
+ ${this.renderActionContent(action)}
532
+ </button>
533
+ `;
534
+ }
535
+
536
+ /** A toolbar control that opens a dropdown of line-prefix choices. */
537
+ private renderMenuAction(action: MenuAction): TemplateResult {
538
+ return html`
539
+ <nile-dropdown
540
+ class="toolbar__menu"
541
+ placement="bottom-start"
542
+ ?disabled=${this.disabled || this.readonly}
543
+ >
544
+ <button
545
+ slot="trigger"
546
+ type="button"
547
+ class="toolbar__button"
548
+ title=${action.title}
549
+ aria-label=${action.title}
550
+ aria-haspopup="menu"
551
+ tabindex="-1"
552
+ @mousedown=${(e: MouseEvent) => e.preventDefault()}
553
+ >
554
+ ${this.renderActionContent(action)}
555
+ </button>
556
+ <nile-menu>
557
+ ${action.items.map(
558
+ item => html`
559
+ <nile-menu-item
560
+ @click=${() => this.runMenuItem(action, item)}
561
+ >
562
+ ${item.glyph
563
+ ? html`<nile-glyph
564
+ slot="prefix"
565
+ name=${item.glyph}
566
+ method="stroke"
567
+ color="currentColor"
568
+ size="18"
569
+ ></nile-glyph>`
570
+ : nothing}
571
+ ${item.label}
572
+ </nile-menu-item>
573
+ `
574
+ )}
575
+ </nile-menu>
576
+ </nile-dropdown>
577
+ `;
578
+ }
579
+
580
+ private renderToolbar(): TemplateResult | typeof nothing {
581
+ if (this.hideToolbar || this.mode === 'preview') return nothing;
582
+ const groups = this.visibleGroups;
583
+ if (groups.length === 0) return nothing;
584
+ return html`
585
+ <div
586
+ class="toolbar"
587
+ part="toolbar"
588
+ role="toolbar"
589
+ aria-label="Formatting"
590
+ >
591
+ ${groups.map(
592
+ (group, groupIndex) => html`
593
+ ${groupIndex > 0
594
+ ? html`<span class="toolbar__divider"></span>`
595
+ : nothing}
596
+ ${group.map(action =>
597
+ action.kind === 'menu'
598
+ ? this.renderMenuAction(action)
599
+ : this.renderActionButton(action)
600
+ )}
601
+ `
602
+ )}
603
+ </div>
604
+ `;
605
+ }
606
+
607
+ render(): TemplateResult {
608
+ const showWrite = this.mode !== 'preview';
609
+ const showPreview = this.mode !== 'write';
610
+
611
+ return html`
612
+ <div
613
+ class=${classMap({
614
+ editor: true,
615
+ 'editor--disabled': this.disabled,
616
+ 'editor--split': this.mode === 'split',
617
+ })}
618
+ part="base"
619
+ >
620
+ <div class="header" part="header">
621
+ <nile-button-toggle-group
622
+ class="tabs"
623
+ aria-label="Editor view"
624
+ .value=${this.mode}
625
+ @nile-change=${(e: CustomEvent) =>
626
+ this.setMode(e.detail.value as MarkdownEditorMode)}
627
+ >
628
+ ${(['write', 'preview', 'split'] as const).map(
629
+ mode => html`
630
+ <nile-button-toggle
631
+ value=${mode}
632
+ ?active=${this.mode === mode}
633
+ >
634
+ <span class="tab__content">
635
+ <nile-glyph
636
+ name=${TAB_GLYPHS[mode]}
637
+ method="stroke"
638
+ color="currentColor"
639
+ size="16"
640
+ ></nile-glyph>
641
+ </span>
642
+ </nile-button-toggle>
643
+ `
644
+ )}
645
+ </nile-button-toggle-group>
646
+ ${this.renderToolbar()}
647
+ </div>
648
+ <div
649
+ class=${classMap({ body: true, 'body--dragging': this.splitDragging })}
650
+ >
651
+ ${showWrite
652
+ ? html`
653
+ <div
654
+ class="pane pane--write"
655
+ style=${this.mode === 'split'
656
+ ? `flex: 0 0 ${this.splitRatio * 100}%`
657
+ : nothing}
658
+ >
659
+ <textarea
660
+ part="textarea"
661
+ rows=${this.rows}
662
+ placeholder=${this.placeholder}
663
+ ?disabled=${this.disabled}
664
+ ?readonly=${this.readonly}
665
+ .value=${this.value}
666
+ @input=${this.handleInput}
667
+ @change=${this.handleChange}
668
+ @keydown=${this.handleKeydown}
669
+ ></textarea>
670
+ </div>
671
+ `
672
+ : nothing}
673
+ ${this.mode === 'split'
674
+ ? html`
675
+ <div
676
+ class="gutter"
677
+ part="gutter"
678
+ role="separator"
679
+ aria-orientation="vertical"
680
+ aria-label="Resize editor and preview"
681
+ title="Drag to resize · double-click to reset"
682
+ @pointerdown=${this.startSplitDrag}
683
+ @dblclick=${this.resetSplit}
684
+ ></div>
685
+ `
686
+ : nothing}
687
+ ${showPreview
688
+ ? html`
689
+ <div class="pane pane--preview" part="preview">
690
+ ${this.value.trim()
691
+ ? html`<nile-markdown .value=${this.value}></nile-markdown>`
692
+ : html`<span class="preview-empty"
693
+ >Nothing to preview</span
694
+ >`}
695
+ </div>
696
+ `
697
+ : nothing}
698
+ </div>
699
+ </div>
700
+ `;
701
+ }
702
+ }
703
+
704
+ export default NileMarkdownEditor;
705
+
706
+ declare global {
707
+ interface HTMLElementTagNameMap {
708
+ 'nile-markdown-editor': NileMarkdownEditor;
709
+ }
710
+ }