// // Copyright 2024 DXOS.org // import { type Extension } from '@codemirror/state'; import { EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view'; import { type CleanupFn, addEventListener } from '@dxos/async'; import { Domino } from '@dxos/ui'; import { GUTTER_WIDTH } from '../blocks/index.ts'; import { treeFacet } from './tree.ts'; // The right-hand strip (`GUTTER_WIDTH`, shared with the grip's left strip) centers the trigger within it. // No size constant: the trigger is a square `dx-button` whose box follows the size scale, so it is centred on a point // by CSS (`translate(-50%, -50%)`) — see `.cm-outliner-menu` below. export type MenuOptions = { icon?: string; }; /** Floating action button positioned beside the active outliner line. */ // TODO(burdon): Replace with popover. export const menu = (options: MenuOptions = {}): Extension => [ ViewPlugin.fromClass( class { view: EditorView; tag: HTMLElement; rafId?: number | null; cleanup?: CleanupFn; constructor(view: EditorView) { this.view = view; // Position context. const container = view.scrollDOM; if (getComputedStyle(container).position === 'static') { container.style.position = 'relative'; } // Outer `dx-anchor` (fires `dx-anchor-activate`, driving the popover) styled as an `dx-button`; // inner element holds the phosphor glyph. Mirrors the drag grip's construction. this.tag = Domino.of('dx-anchor') .classNames('dx-control dx-button dx-button-square cm-popover-trigger cm-outliner-menu') .attributes({ 'data-variant': 'ghost', 'data-size': 'sm' }) .append( Domino.of('div') .classNames('cm-popover-trigger-icon') .append(Domino.svg(options.icon ?? 'ph--dots-three-vertical--regular')), ).root; container.appendChild(this.tag); // Capture-phase on the document, not `container`: the trigger is `position: fixed`, so its // coordinates go stale when ANY ancestor scrolls — and when the editor grows to fit its // content the scroller is the surrounding plank, which never fires on `scrollDOM`. const handler = () => this.scheduleUpdate(); this.cleanup = addEventListener(document, 'scroll', handler, { capture: true, passive: true }); this.scheduleUpdate(); } destroy() { this.cleanup?.(); this.tag.remove(); if (this.rafId != null) { cancelAnimationFrame(this.rafId); } } update(update: ViewUpdate) { this.tag.dataset.focused = update.view.hasFocus ? 'true' : 'false'; if (!update.view.hasFocus) { return; } // TODO(burdon): Timer to fade in/out. /*if (update.transactions.some((tr) => tr.effects.some((effect) => effect.is(openEffect)))) { this.tag.style.display = 'none'; this.tag.classList.add('opacity-10'); } else if (update.transactions.some((tr) => tr.effects.some((effect) => effect.is(closeEffect)))) { this.tag.style.display = ''; } else */ if ( update.docChanged || update.focusChanged || update.geometryChanged || update.selectionSet || update.viewportChanged ) { this.scheduleUpdate(); } } updateButtonPosition() { const { x, width } = this.view.contentDOM.getBoundingClientRect(); const pos = this.view.state.selection.main.head; // The actions act on an item; a prose line has none to offer. if (!this.view.state.facet(treeFacet).find(pos)) { this.tag.style.display = 'none'; return; } const line = this.view.lineBlockAt(pos); const coords = this.view.coordsAtPos(line.from); if (!coords) { // No resolvable line (e.g. the caret's item was just deleted): hide rather than leave the // trigger stranded at its previous position. this.tag.style.display = 'none'; return; } // The centre of the line's first row, and of the 3rem gutter immediately right of the content // (mirrors the grip). Both name a point; the CSS transform centres the box on it. const offsetTop = (coords.top + coords.bottom) / 2; const offsetLeft = x + width + GUTTER_WIDTH / 2; this.tag.style.top = `${offsetTop}px`; this.tag.style.left = `${offsetLeft}px`; this.tag.style.display = ''; } scheduleUpdate() { if (this.rafId != null) { cancelAnimationFrame(this.rafId); } this.rafId = requestAnimationFrame(this.updateButtonPosition.bind(this)); } }, ), styles, ]; const styles = EditorView.theme({ // `dx-button` owns the box (size, hover); this only pins it and gates visibility on editor focus. '.cm-popover-trigger': { position: 'fixed', opacity: '0', cursor: 'pointer', }, // Scoped to this menu's own trigger rather than the shared `cm-popover-trigger`: that class is also // on the inline `dx-anchor` decorations `popover.ts` puts in the document, and centring those on a // point would shift them out from under their own text. '.cm-outliner-menu': { // `left`/`top` name the centre point; the browser centres the real box on it, whatever its size. transform: 'translate(-50%, -50%)', }, '.cm-popover-trigger-icon': { display: 'grid', placeContent: 'center', fontSize: '16px', color: 'var(--color-fg-muted, currentColor)', }, '&:focus-within .cm-popover-trigger': { opacity: '1', }, // Hide the trigger while a block drag is in flight (the scroller carries `cm-blockDragging`). '.cm-scroller.cm-blockDragging .cm-popover-trigger': { opacity: '0 !important', pointerEvents: 'none', }, });