From 0c0c5961bd93b1325c94eb481d93a0a7ebda18ba Mon Sep 17 00:00:00 2001 From: plmn95 Date: Wed, 23 Sep 2026 18:11:01 +0300 Subject: [PATCH] Add hover and focus hints to docs editor toolbar --- src/lib/suggestions/editor.ts | 7 +++ src/lib/suggestions/toolbar-tooltip.ts | 85 ++++++++++++++++++++++++++ src/styles/editor.css | 1 + tests/editor.spec.ts | 27 +++++++- 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 src/lib/suggestions/toolbar-tooltip.ts diff --git a/src/lib/suggestions/editor.ts b/src/lib/suggestions/editor.ts index d81119f..9f7085e 100644 --- a/src/lib/suggestions/editor.ts +++ b/src/lib/suggestions/editor.ts @@ -8,6 +8,7 @@ import { editorViewCtx } from '@milkdown/kit/core'; import { undoCommand, redoCommand } from '@milkdown/kit/plugin/history'; import { callCommand } from '@milkdown/kit/utils'; import { cleanEditorMarkdown, equivalent, validateBody } from './document.mjs'; +import { installToolbarTooltip } from './toolbar-tooltip'; export async function createDocumentEditor(root: HTMLElement, markdown: string, onChange = (_text: string) => {}) { validateBody(markdown); @@ -58,6 +59,12 @@ export async function createDocumentEditor(root: HTMLElement, markdown: string, event.preventDefault(); target.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, cancelable: true})); } }); + const toolbar = root.querySelector('.milkdown-top-bar'); + if (toolbar) { + const removeTooltip = installToolbarTooltip(root, toolbar); + const destroy = editor.destroy.bind(editor); + editor.destroy = async () => { removeTooltip(); await destroy(); }; + } editor.on(listener => listener.markdownUpdated((_ctx, markdown, previous) => { if (markdown !== previous) onChange(cleanEditorMarkdown(markdown)); })); diff --git a/src/lib/suggestions/toolbar-tooltip.ts b/src/lib/suggestions/toolbar-tooltip.ts new file mode 100644 index 0000000..d5aa28e --- /dev/null +++ b/src/lib/suggestions/toolbar-tooltip.ts @@ -0,0 +1,85 @@ +export function installToolbarTooltip(root: HTMLElement, toolbar: HTMLElement) { + const events = new AbortController(); + const tooltip = document.createElement('div'); + tooltip.className = 'editor-toolbar-tooltip'; + tooltip.setAttribute('role', 'tooltip'); + tooltip.hidden = true; + root.append(tooltip); + + let active: HTMLButtonElement | null = null; + let dismissed: HTMLButtonElement | null = null; + let showTimer: number | undefined; + let hideTimer: number | undefined; + + const clearTimers = () => { + window.clearTimeout(showTimer); + window.clearTimeout(hideTimer); + }; + const hide = () => { + clearTimers(); + tooltip.hidden = true; + active = null; + }; + const buttonFrom = (target: EventTarget | null) => { + const button = target instanceof Element ? target.closest('.top-bar-item') : null; + return button && toolbar.contains(button) ? button : null; + }; + const position = () => { + if (!active || tooltip.hidden) return; + const button = active.getBoundingClientRect(); + const tip = tooltip.getBoundingClientRect(); + const top = button.bottom + tip.height + 8 <= innerHeight ? button.bottom + 8 : button.top - tip.height - 8; + tooltip.style.top = `${Math.max(8, top)}px`; + tooltip.style.left = `${Math.max(8, Math.min(button.left + button.width / 2 - tip.width / 2, innerWidth - tip.width - 8))}px`; + }; + const show = (button: HTMLButtonElement) => { + if (dismissed === button) return; + const label = button.querySelector('.sr-only')?.textContent?.trim(); + if (!label) return; + clearTimers(); + active = button; + tooltip.textContent = label; + tooltip.hidden = false; + position(); + }; + toolbar.addEventListener('pointerover', event => { + const button = buttonFrom(event.target); + if (!button || button.contains(event.relatedTarget as Node)) return; + if (active && active !== button) hide(); + window.clearTimeout(showTimer); + window.clearTimeout(hideTimer); + showTimer = window.setTimeout(() => show(button), 300); + }, { signal: events.signal }); + toolbar.addEventListener('pointerout', event => { + const button = buttonFrom(event.target); + if (!button || button.contains(event.relatedTarget as Node)) return; + if (dismissed === button) dismissed = null; + window.clearTimeout(showTimer); + if (active === button && !tooltip.contains(event.relatedTarget as Node) && !button.matches(':focus')) { + hideTimer = window.setTimeout(hide, 120); + } + }, { signal: events.signal }); + toolbar.addEventListener('focusin', event => { + const button = buttonFrom(event.target); + if (button) show(button); + }, { signal: events.signal }); + toolbar.addEventListener('focusout', event => { + const button = buttonFrom(event.target); + if (dismissed === button) dismissed = null; + if (active === button && !button?.matches(':hover')) hide(); + }, { signal: events.signal }); + toolbar.addEventListener('keydown', event => { + if (event.key === 'Escape' && active) { + dismissed = active; + hide(); + event.stopPropagation(); + } + }, { signal: events.signal }); + tooltip.addEventListener('pointerenter', () => window.clearTimeout(hideTimer), { signal: events.signal }); + tooltip.addEventListener('pointerleave', () => { + if (!active?.matches(':hover') && !active?.matches(':focus')) hide(); + }, { signal: events.signal }); + window.addEventListener('scroll', position, { capture: true, signal: events.signal }); + window.addEventListener('resize', position, { signal: events.signal }); + return () => { events.abort(); hide(); tooltip.remove(); }; +} diff --git a/src/styles/editor.css b/src/styles/editor.css index 069811f..a691a74 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -15,6 +15,7 @@ label{display:block;margin:24px 0 10px;font-size:13px;}textarea{display:block;wi .milkdown .milkdown-top-bar .top-bar-item{margin:4px;width:32px;height:32px;} .milkdown .milkdown-top-bar .top-bar-divider{margin:6px;height:20px;background:var(--border);} .milkdown .milkdown-top-bar .top-bar-divider:has(+.top-bar-divider){display:none;} +.editor-toolbar-tooltip{position:fixed;z-index:50;max-width:min(260px,calc(100vw - 16px));padding:6px 9px;border:1px solid #66666d;border-radius:4px;background:#252529;color:#f2f2f3;box-shadow:0 4px 16px #0009;font-size:12px;line-height:1.4;white-space:nowrap;pointer-events:auto;} .milkdown .milkdown-table-block{overflow-x:auto;padding:20px 1px;} .milkdown .ProseMirror table{min-width:680px;table-layout:auto;width:100%;} .milkdown .ProseMirror td,.milkdown .ProseMirror th{min-width:130px;border-color:var(--border);vertical-align:top;padding:10px 12px;} diff --git a/tests/editor.spec.ts b/tests/editor.spec.ts index 7d17be5..cfd1305 100644 --- a/tests/editor.spec.ts +++ b/tests/editor.spec.ts @@ -106,10 +106,33 @@ test('problem reports work independently of the visual editor', async ({page}) = test('toolbar controls have names and respond to the keyboard', async ({page}) => { await page.goto('edit/concepts/signal-chain/'); - await expect(page.getByRole('textbox',{name:'Page content'})).toBeVisible(); + await expect(page.getByRole('textbox',{name:'Page content'})).toBeVisible({timeout:15000}); const bold=page.getByRole('button',{name:'Bold',exact:true}); + const tooltip=page.getByRole('tooltip'); + await bold.hover(); + await expect(tooltip).toHaveText('Bold'); + await page.screenshot({path:'/tmp/vizard-editor-tooltip-desktop.png'}); + await tooltip.hover(); + await expect(tooltip).toBeVisible(); + await page.mouse.move(0, 0); + await expect(tooltip).toBeHidden(); await bold.focus(); await bold.press('Enter'); await expect(bold).toHaveClass(/active/); + await expect(tooltip).toHaveText('Bold'); + await bold.press('Escape'); + await expect(tooltip).toBeHidden(); await page.getByRole('button',{name:'Undo',exact:true}).focus(); - await expect(page.getByRole('button',{name:'Insert table',exact:true})).toBeVisible(); + await expect(tooltip).toHaveText('Undo'); + for (const name of ['Bold','Italic','Inline code','Bullet list','Numbered list','Insert link','Insert table','Undo','Redo']) { + await page.getByRole('button',{name,exact:true}).focus(); + await expect(tooltip).toHaveText(name); + } + await page.setViewportSize({width:390,height:844}); + await page.getByRole('button',{name:'Insert table',exact:true}).focus(); + await expect(tooltip).toHaveText('Insert table'); + expect(await tooltip.evaluate(element => { + const box = element.getBoundingClientRect(); + return box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight; + })).toBe(true); + await page.screenshot({path:'/tmp/vizard-editor-tooltip-mobile.png'}); });