import { html, css, TemplateResult, CSSResultArray, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import NileElement from '../../internal/nile-element'; import { renderGlyph } from '../../internal/glyph'; import type { ToolbarState } from '../engine/selection-state'; import type { WysiwygCommandDetail } from '../types'; import type { AuthoredToolbarItem } from '../nile-wysiwyg-toolbar/nile-wysiwyg-toolbar'; import '../../nile-lite-tooltip/nile-lite-tooltip'; /** A control the bubble menu knows how to render, keyed by its authored name. */ interface BubbleDef { /** The command dispatched on click. */ command: WysiwygCommandDetail['name']; label: string; /** nile-glyph icon name (ng-* names are stroke-drawn). */ icon: string; /** Static attrs sent with the command (e.g. alignment direction). */ attrs?: WysiwygCommandDetail['attrs']; active?: (state: ToolbarState) => boolean; } /** * Registry of controls addressable by ``. * Icons/labels mirror the main toolbar so both surfaces look identical. */ const BUBBLE_ITEMS: Record = { bold: { command: 'bold', label: 'Bold', icon: 'format_bold', active: s => s.bold }, italic: { command: 'italic', label: 'Italic', icon: 'format_italic', active: s => s.italic }, underline: { command: 'underline', label: 'Underline', icon: 'format_underline', active: s => s.underline, }, strike: { command: 'strike', label: 'Strikethrough', icon: 'ng-strikethrough', active: s => s.strike, }, code: { command: 'code', label: 'Inline code', icon: 'ng-code', active: s => s.code }, subscript: { command: 'subscript', label: 'Subscript', icon: 'ng-subscript', active: s => s.subscript, }, superscript: { command: 'superscript', label: 'Superscript', icon: 'ng-superscript', active: s => s.superscript, }, link: { command: 'openLink', label: 'Link', icon: 'link_2', active: s => s.linkActive }, bulletList: { command: 'bulletList', label: 'Bulleted list', icon: 'format_list_bulleted', active: s => s.inBulletList, }, orderedList: { command: 'orderedList', label: 'Numbered list', icon: 'format_list_numbered', active: s => s.inOrderedList, }, alignLeft: { command: 'align', label: 'Align left', icon: 'format_align_left', attrs: { align: 'left' }, active: s => s.align === 'left', }, alignCenter: { command: 'align', label: 'Align center', icon: 'format_align_middle', attrs: { align: 'center' }, active: s => s.align === 'center', }, alignRight: { command: 'align', label: 'Align right', icon: 'format_align_right', attrs: { align: 'right' }, active: s => s.align === 'right', }, clearFormatting: { command: 'clearFormatting', label: 'Clear formatting', icon: 'format_clear', }, }; /** Built-in default when no `` children are authored. */ const DEFAULT_BUBBLE = ['bold', 'italic', 'underline', 'strike', 'link']; /** * Balloon toolbar shown over non-empty selections. The host owns positioning * (absolute left/top) and visibility; commands bubble up as * `wysiwyg-command` events, same contract as the main toolbar. */ @customElement('nile-wysiwyg-bubble-menu') export class NileWysiwygBubbleMenu extends NileElement { @property({ type: Boolean, reflect: true }) open = false; @property({ attribute: false }) toolbarState?: ToolbarState; /** * Structured item list authored in light DOM (from * `` children). `'|'` is a separator. Falls back to * {@link DEFAULT_BUBBLE} when empty. */ @property({ attribute: false }) items?: AuthoredToolbarItem[]; public static get styles(): CSSResultArray { return [ css` :host { font-family: var( --nile-font-family-sans-serif, var(--ng-font-family-body, system-ui, -apple-system, sans-serif) ); position: absolute; z-index: 15; display: none; } :host([open]) { display: block; } .bubble { display: flex; gap: 2px; padding: 4px; background: var(--nile-wysiwyg-popover-bg, #ffffff); border: 1px solid var(--nile-wysiwyg-border-color, var(--nile-colors-neutral-400, #d0d5dd)); border-radius: var(--nile-wysiwyg-radius, 8px); box-shadow: var( --nile-wysiwyg-popover-shadow, 0 4px 12px rgba(16, 24, 40, 0.12) ); } button { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: 5px; background: transparent; cursor: pointer; font-size: 13px; font-weight: 600; color: var(--nile-colors-neutral-700, #344054); } button:hover { background: var(--nile-colors-neutral-100, #f2f4f7); } button[data-active] { background: var(--nile-colors-primary-50, var(--ng-componentcolors-utility-blue-50, #eff4ff)); color: var(--nile-colors-primary-700, var(--ng-componentcolors-utility-blue-700, #1d4ed8)); } .divider { align-self: stretch; width: 1px; margin: 3px 2px; background: var(--nile-colors-neutral-300, #e4e7ec); } `, ]; } /** * True when this instance is an author's declarative `` * container in an editor's light DOM (holding `` * children). It renders nothing — the editor reads the items and renders the * real bubble menu in its own shadow DOM. The editor's internal instance * lives in a shadow root, so `closest` never matches it. */ private get isAuthoringContainer(): boolean { return !!this.closest('nile-wysiwyg-editor'); } public render(): TemplateResult { if (this.isAuthoringContainer) return html``; const state = this.toolbarState; const entries: AuthoredToolbarItem[] = this.items && this.items.length ? this.items : DEFAULT_BUBBLE.map(name => ({ name })); return html`
e.preventDefault()}> ${entries.map(entry => this.renderEntry(entry, state))}
`; } private renderEntry( entry: AuthoredToolbarItem, state?: ToolbarState ): TemplateResult | typeof nothing { if (entry.name === '|') { return html``; } const base = BUBBLE_ITEMS[entry.name]; const label = entry.label || base?.label || entry.name; const icon = entry.icon || base?.icon; // Unknown command with no icon to draw → nothing to render. if (!icon) return nothing; const command = base?.command ?? (entry.name as WysiwygCommandDetail['name']); const attrs = base?.attrs; const isActive = base?.active && state ? base.active(state) : false; return html` `; } } declare global { interface HTMLElementTagNameMap { 'nile-wysiwyg-bubble-menu': NileWysiwygBubbleMenu; } }