/** * Copyright Aquera Inc 2023 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { html, nothing } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { styles } from './nile-markdown-editor.css'; import NileElement from '../internal/nile-element'; import '../nile-markdown/nile-markdown'; import '../nile-icon'; import '../nile-button-toggle-group/nile-button-toggle-group'; import '../nile-button-toggle/nile-button-toggle'; import '../nile-dropdown/nile-dropdown'; import '../nile-menu/nile-menu'; import '../nile-menu-item/nile-menu-item'; import type { CSSResultGroup, TemplateResult } from 'lit'; export type MarkdownEditorMode = 'write' | 'preview' | 'split'; /** A toolbar action that wraps the selection with markdown delimiters. */ interface WrapAction { name: string; title: string; kind: 'wrap'; prefix: string; suffix: string; placeholder: string; icon?: string; glyph?: string; } /** A toolbar action that toggles a prefix on each selected line. */ interface LineAction { name: string; title: string; kind: 'line'; prefix: string; numbered?: boolean; icon?: string; glyph?: string; } interface LinkAction { name: string; title: string; kind: 'link'; icon?: string; glyph?: string; } /** A single option inside a {@link MenuAction} dropdown. */ interface MenuItem { label: string; /** The line prefix applied when this item is chosen, e.g. `'## '`. */ prefix: string; /** Optional nile-glyph name shown beside the label. */ glyph?: string; } /** A toolbar action that opens a dropdown of line-prefix choices. */ interface MenuAction { name: string; title: string; kind: 'menu'; items: MenuItem[]; icon?: string; glyph?: string; } type ToolbarAction = WrapAction | LineAction | LinkAction | MenuAction; /** Toolbar actions, grouped — groups are separated by vertical dividers. */ const TOOLBAR_GROUPS: ToolbarAction[][] = [ [ { name: 'heading', title: 'Heading', kind: 'menu', glyph: 'ng-heading', items: [ { label: 'Heading 1', prefix: '# ', glyph: 'ng-heading-1' }, { label: 'Heading 2', prefix: '## ', glyph: 'ng-heading-2' }, { label: 'Heading 3', prefix: '### ', glyph: 'ng-heading-3' }, { label: 'Heading 4', prefix: '#### ', glyph: 'ng-heading-4' }, { label: 'Heading 5', prefix: '##### ', glyph: 'ng-heading-5' }, { label: 'Heading 6', prefix: '###### ', glyph: 'ng-heading-6' }, ], }, { name: 'bold', title: 'Bold (Ctrl+B)', kind: 'wrap', prefix: '**', suffix: '**', placeholder: 'bold text', icon: 'format_bold', }, { name: 'italic', title: 'Italic (Ctrl+I)', kind: 'wrap', prefix: '_', suffix: '_', placeholder: 'italic text', icon: 'format_italic', }, { name: 'strikethrough', title: 'Strikethrough', kind: 'wrap', prefix: '~~', suffix: '~~', placeholder: 'text', glyph: 'ng-strikethrough', }, ], [ { name: 'quote', title: 'Quote', kind: 'line', prefix: '> ', glyph: 'ng-quote' }, { name: 'code', title: 'Code', kind: 'wrap', prefix: '`', suffix: '`', placeholder: 'code', glyph: 'ng-code', }, ], [ { name: 'ul', title: 'Bulleted list', kind: 'line', prefix: '- ', icon: 'format_list_bulleted', }, { name: 'ol', title: 'Numbered list', kind: 'line', prefix: '1. ', numbered: true, icon: 'format_list_numbered', }, ], [{ name: 'link', title: 'Link (Ctrl+K)', kind: 'link', icon: 'link_2' }], ]; const TOOLBAR_ACTIONS: ToolbarAction[] = TOOLBAR_GROUPS.flat(); /** Glyph shown alongside each view-mode tab label. */ const TAB_GLYPHS: Record = { write: 'ng-pencil', preview: 'ng-eye', split: 'ng-square-split-horizontal', }; /** * Nile markdown editor component. * * @tag nile-markdown-editor */ /** * @summary A GitHub-style markdown editor with a formatting toolbar and a * live preview rendered by `nile-markdown`. * @status experimental * * @dependency nile-markdown * @dependency nile-icon * @dependency nile-glyph * @dependency nile-button-toggle-group * @dependency nile-button-toggle * * @attr {string} tools - JSON-array allowlist of toolbar tools to show, e.g. * `tools='["bold","italic","link"]'`. Valid names: heading, bold, italic, * strikethrough, quote, code, ul, ol, link. Empty shows all. * * @event nile-input - Emitted with `{ value }` on every keystroke or toolbar action. * @event nile-change - Emitted with `{ value }` when the editor loses focus after an edit. * @event nile-mode-change - Emitted with `{ mode }` when the write/preview/split mode changes. * * @csspart base - The component's base wrapper. * @csspart header - The header containing the tabs and toolbar. * @csspart toolbar - The formatting toolbar. * @csspart textarea - The markdown source textarea. * @csspart preview - The rendered preview pane. */ @customElement('nile-markdown-editor') export class NileMarkdownEditor extends NileElement { static styles: CSSResultGroup = styles; /** The markdown source. */ @property() value = ''; /** Placeholder shown when the editor is empty. */ @property() placeholder = 'Write markdown here…'; /** Disables the editor. */ @property({ type: Boolean, reflect: true }) disabled = false; /** Makes the source read-only while still allowing mode switching. */ @property({ type: Boolean, reflect: true }) readonly = false; /** Number of visible text rows in write mode. */ @property({ type: Number }) rows = 8; /** Active view: `write`, `preview`, or `split`. */ @property({ reflect: true }) mode: MarkdownEditorMode = 'write'; /** Hides the formatting toolbar. */ @property({ type: Boolean, attribute: 'hide-toolbar' }) hideToolbar = false; /** * Allowlist of toolbar tool names to show. Accepts a JSON array via the * `tools` attribute (e.g. `tools='["bold","italic","link"]'`), a * comma-separated string, or an array when set as a property. When empty * (default) every tool is shown. Valid names: `heading`, `bold`, `italic`, * `strikethrough`, `quote`, `code`, `ul`, `ol`, `link`. */ @property({ converter: { fromAttribute: (value: string | null) => { if (!value) return []; try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed.map(s => String(s).trim()).filter(Boolean); } } catch { // Not JSON — fall back to a comma-separated string. } return value.split(',').map(s => s.trim()).filter(Boolean); }, toAttribute: (value: string[]) => JSON.stringify(value ?? []), }, }) tools: string[] = []; /** Parsed allowlist, or `null` when no allowlist is set (show everything). */ private get allowedTools(): Set | null { const names = (this.tools ?? []).map(s => s.trim()).filter(Boolean); return names.length ? new Set(names) : null; } /** Toolbar groups after applying the allowlist; empty groups are dropped. */ private get visibleGroups(): ToolbarAction[][] { const allowed = this.allowedTools; if (!allowed) return TOOLBAR_GROUPS; return TOOLBAR_GROUPS.map(group => group.filter(action => allowed.has(action.name)) ).filter(group => group.length > 0); } @query('textarea') private textarea?: HTMLTextAreaElement; @query('.body') private bodyEl?: HTMLElement; /** * Fraction of the body width given to the write pane in split mode * (the rest goes to the preview). Clamped to a sensible range while * dragging the splitter. */ @state() private splitRatio = 0.5; /** Whether the split divider is currently being dragged. */ @state() private splitDragging = false; /** Begins a pointer-driven resize of the split divider. */ private startSplitDrag = (e: PointerEvent) => { if (this.mode !== 'split') return; const body = this.bodyEl; if (!body) return; e.preventDefault(); this.splitDragging = true; const rect = body.getBoundingClientRect(); const MIN = 0.15; const MAX = 0.85; const onMove = (ev: PointerEvent) => { if (rect.width === 0) return; const ratio = (ev.clientX - rect.left) / rect.width; this.splitRatio = Math.min(MAX, Math.max(MIN, ratio)); }; const onUp = () => { this.splitDragging = false; window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); }; /** Resets the splitter to a 50/50 layout (double-click affordance). */ private resetSplit = () => { this.splitRatio = 0.5; }; /** Moves focus to the textarea. */ focus(options?: FocusOptions): void { this.textarea?.focus(options); } blur(): void { this.textarea?.blur(); } private setMode(mode: MarkdownEditorMode) { if (this.mode === mode) return; this.mode = mode; this.emit('nile-mode-change', { mode }, true, true); } private handleInput = (e: Event) => { this.value = (e.target as HTMLTextAreaElement).value; this.emit('nile-input', { value: this.value }, true, true); }; private handleChange = () => { this.emit('nile-change', { value: this.value }, true, true); }; private handleKeydown = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey)) return; const key = e.key.toLowerCase(); const action = { b: 'bold', i: 'italic', k: 'link' }[ key as 'b' | 'i' | 'k' ]; if (!action) return; // Respect the allowlist: a hidden tool's shortcut is disabled too. const allowed = this.allowedTools; if (allowed && !allowed.has(action)) return; e.preventDefault(); this.runAction(TOOLBAR_ACTIONS.find(a => a.name === action)!); }; /** Replaces the current selection and restores focus/selection. */ private replaceSelection( start: number, end: number, replacement: string, selectStart: number, selectEnd: number ) { const ta = this.textarea; if (!ta) return; ta.focus(); // Select the range to overwrite, then insert via the browser's editing // pipeline so the change is recorded on the native undo/redo stack. // Assigning `ta.value` directly would wipe that stack and break Ctrl/Cmd+Z. ta.setSelectionRange(start, end); const inserted = document.execCommand('insertText', false, replacement); if (inserted) { // execCommand dispatched a native `input` event, so `handleInput` // already synced `value` and emitted `nile-input`; just fix selection. ta.setSelectionRange(selectStart, selectEnd); return; } // Fallback for environments without execCommand support. ta.value = ta.value.slice(0, start) + replacement + ta.value.slice(end); ta.setSelectionRange(selectStart, selectEnd); this.value = ta.value; this.emit('nile-input', { value: this.value }, true, true); } /** Wraps (or unwraps) the selection with prefix/suffix delimiters. */ private applyWrap(action: WrapAction) { const ta = this.textarea; if (!ta) return; const { selectionStart: start, selectionEnd: end, value } = ta; const { prefix, suffix } = action; const selected = value.slice(start, end); // Toggle off when the selection is already wrapped const before = value.slice(Math.max(0, start - prefix.length), start); const after = value.slice(end, end + suffix.length); if (before === prefix && after === suffix) { this.replaceSelection( start - prefix.length, end + suffix.length, selected, start - prefix.length, end - prefix.length ); return; } const content = selected || action.placeholder; this.replaceSelection( start, end, prefix + content + suffix, start + prefix.length, start + prefix.length + content.length ); } /** Toggles a markdown prefix on every line in the selection. */ private applyLinePrefix(action: LineAction) { const ta = this.textarea; if (!ta) return; const { selectionStart: start, selectionEnd: end, value } = ta; // Expand the selection to whole lines const lineStart = value.lastIndexOf('\n', start - 1) + 1; const lineEndIndex = value.indexOf('\n', end); const lineEnd = lineEndIndex === -1 ? value.length : lineEndIndex; const block = value.slice(lineStart, lineEnd); const lines = block.split('\n'); const matcher = action.numbered ? /^\d+\.\s/ : undefined; const hasPrefix = lines.every(line => matcher ? matcher.test(line) : line.startsWith(action.prefix) ); const replaced = lines .map((line, i) => { if (hasPrefix) { return matcher ? line.replace(matcher, '') : line.slice(action.prefix.length); } return action.numbered ? `${i + 1}. ${line}` : action.prefix + line; }) .join('\n'); this.replaceSelection( lineStart, lineEnd, replaced, lineStart, lineStart + replaced.length ); } /** Inserts a `[text](url)` link, selecting the URL for quick editing. */ private insertLink() { const ta = this.textarea; if (!ta) return; const { selectionStart: start, selectionEnd: end, value } = ta; const text = value.slice(start, end) || 'text'; const url = 'url'; const replacement = `[${text}](${url})`; const urlStart = start + text.length + 3; // "[text](".length this.replaceSelection( start, end, replacement, urlStart, urlStart + url.length ); } private runAction(action: ToolbarAction) { if (this.readonly || this.disabled) return; if (this.mode === 'preview') this.setMode('write'); switch (action.kind) { case 'wrap': this.applyWrap(action); break; case 'line': this.applyLinePrefix(action); break; case 'link': this.insertLink(); break; case 'menu': // The dropdown items drive the edits via `runMenuItem`; nothing to do // when the trigger itself is activated. break; } } /** Applies a heading-style line prefix chosen from a dropdown menu. */ private runMenuItem(action: MenuAction, item: MenuItem) { if (this.readonly || this.disabled) return; if (this.mode === 'preview') this.setMode('write'); this.applyLinePrefix({ name: action.name, title: action.title, kind: 'line', prefix: item.prefix, }); } /** Renders the glyph/icon shown inside a toolbar control. */ private renderActionContent(action: ToolbarAction): TemplateResult { if (action.icon) { return html``; } // `ng-*` glyph names map to nile-glyph icons; anything else is plain text. if (action.glyph?.startsWith('ng-')) { return html``; } return html` ${action.glyph} `; } /** A standard click-to-apply toolbar button. */ private renderActionButton(action: ToolbarAction): TemplateResult { return html` `; } /** A toolbar control that opens a dropdown of line-prefix choices. */ private renderMenuAction(action: MenuAction): TemplateResult { return html` ${action.items.map( item => html` this.runMenuItem(action, item)} > ${item.glyph ? html`` : nothing} ${item.label} ` )} `; } private renderToolbar(): TemplateResult | typeof nothing { if (this.hideToolbar || this.mode === 'preview') return nothing; const groups = this.visibleGroups; if (groups.length === 0) return nothing; return html` `; } render(): TemplateResult { const showWrite = this.mode !== 'preview'; const showPreview = this.mode !== 'write'; return html`
${this.renderToolbar()} this.setMode(e.detail.value as MarkdownEditorMode)} > ${(['write', 'preview', 'split'] as const).map( mode => html` ` )}
${showWrite ? html`
` : nothing} ${this.mode === 'split' ? html` ` : nothing} ${showPreview ? html`
${this.value.trim() ? html`` : html`Nothing to preview`}
` : nothing}
`; } } export default NileMarkdownEditor; declare global { interface HTMLElementTagNameMap { 'nile-markdown-editor': NileMarkdownEditor; } }