Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/lib/suggestions/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<HTMLElement>('.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));
}));
Expand Down
85 changes: 85 additions & 0 deletions src/lib/suggestions/toolbar-tooltip.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>('.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(); };
}
1 change: 1 addition & 0 deletions src/styles/editor.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 25 additions & 2 deletions tests/editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'});
});
Loading