diff --git a/frontend/e2e/atlas-note-markdown.spec.ts b/frontend/e2e/atlas-note-markdown.spec.ts index 0d516cae..462ec372 100644 --- a/frontend/e2e/atlas-note-markdown.spec.ts +++ b/frontend/e2e/atlas-note-markdown.spec.ts @@ -384,3 +384,59 @@ test('a note holding markdown renders real elements, keeps the edit surface insi await deleteSticky(page, stickyAfterReload) }) + +// Regression (goal 0253): the formatting toolbar used to mount INSIDE +// the note node (Crepe's default TooltipProvider parent), where the +// node's box clipped it to nothing -- reproduced live as "element in +// the DOM, zero bounding box". It now floats at body level through the +// same provider's own `root` option: a real on-screen box, outside the +// selected text it acts on, and its buttons apply without ending the +// edit session (the sticky's outside-press commit excludes it). +test('selecting note text shows the floating toolbar outside the text, and Bold round-trips into the committed note', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + + await placeNoteClear(page, board) + await expect(stickyEditor(page)).toBeVisible() + await fillSticky(page, 'Quarterly review meeting notes') + // ControlOrMeta: select-all is Ctrl+A on the Linux CI runner and + // Cmd+A locally -- Meta alone selects nothing on Linux, leaving the + // toolbar honestly hidden. + await page.keyboard.press('ControlOrMeta+a') + + const toolbar = page.getByTestId('milkdown-selection-toolbar') + await expect(toolbar).toBeVisible() + const toolbarBox = await toolbar.boundingBox() + if (!toolbarBox) throw new Error('toolbar has no bounding box') + expect(toolbarBox.width).toBeGreaterThan(40) + + // Never covering the text it acts on: the toolbar's box must not + // intersect the selected text's own rect (floating-ui places it + // above/below the selection with an offset). + const selRect = await page.evaluate(() => { + const range = window.getSelection()?.getRangeAt(0) + const r = range?.getBoundingClientRect() + return r ? { x: r.x, y: r.y, width: r.width, height: r.height } : null + }) + if (!selRect) throw new Error('no live selection rect') + const overlaps = !( + toolbarBox.x + toolbarBox.width <= selRect.x || + selRect.x + selRect.width <= toolbarBox.x || + toolbarBox.y + toolbarBox.height <= selRect.y || + selRect.y + selRect.height <= toolbarBox.y + ) + expect(overlaps, 'the toolbar must not cover the selected text').toBe(false) + + // Bold applies from the toolbar WITHOUT ending the edit session, + // and survives the commit: the resting note renders a real . + await page.getByTestId('milkdown-toolbar-bold').click() + await expect(page.getByTestId('milkdown-toolbar-bold')).toHaveAttribute('aria-pressed', 'true') + await blurSticky(page) + const note = page.getByTestId('atlas-sticky-note').filter({ hasText: 'Quarterly review meeting notes' }) + await expect(note.locator('strong')).toHaveText('Quarterly review meeting notes') + await expect(toolbar).not.toBeVisible() + + await deleteSticky(page, note) +}) diff --git a/frontend/src/atlas/AtlasStickyNode.tsx b/frontend/src/atlas/AtlasStickyNode.tsx index 3efbc533..956cb9c5 100644 --- a/frontend/src/atlas/AtlasStickyNode.tsx +++ b/frontend/src/atlas/AtlasStickyNode.tsx @@ -149,6 +149,11 @@ export const AtlasStickyNode = memo(function AtlasStickyNode({ data, selected }: const target = e.target as Element | null if (wrapRef.current?.contains(target)) return if (target?.closest('.react-flow__resize-control')) return + // The floating selection toolbar (goal 0253) lives at body + // level -- outside wrapRef by design, so board zoom/clipping + // can't touch it -- but a press on it is part of THIS edit + // session, never an outside press. + if (target?.closest('[data-milkdown-selection-toolbar]')) return commitRef.current() } const handleWindowBlur = () => { diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 6ffc3b60..0b7997b9 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -59,5 +59,11 @@ "deleteRow": "Delete row", "booleanTrue": "Yes", "booleanFalse": "No" + }, + "formattingToolbar": { + "bold": "Bold", + "italic": "Italic", + "strikethrough": "Strikethrough", + "code": "Code" } } diff --git a/frontend/src/shared/MilkdownEditor.module.css b/frontend/src/shared/MilkdownEditor.module.css index 25eea726..cbe3566d 100644 --- a/frontend/src/shared/MilkdownEditor.module.css +++ b/frontend/src/shared/MilkdownEditor.module.css @@ -117,3 +117,29 @@ outline: none; border-color: var(--borderColor-accent-emphasis); } + +/* The floating selection toolbar (goal 0253): appended to + document.body by the kit's own TooltipProvider (root option), so it + floats at UI scale beside the selection -- never inside the note + node, never clipped by its box, never shrunk by the board's zoom + transform. TooltipProvider writes left/top and toggles data-show; + this rule owns everything else. z-index sits above the board's own + node tiers (inline, small integers) and below nothing it needs to + defer to -- the toolbar only exists while its editor has focus. */ +.selectionToolbar { + position: absolute; + top: 0; + left: 0; + z-index: 100; + display: flex; + gap: 2px; + padding: 2px; + background: var(--overlay-bgColor, var(--bgColor-default)); + border: 1px solid var(--borderColor-default); + border-radius: var(--borderRadius-medium); + box-shadow: var(--shadow-floating-small, var(--shadow-resting-medium)); +} + +.selectionToolbar[data-show='false'] { + display: none; +} diff --git a/frontend/src/shared/MilkdownEditor.tsx b/frontend/src/shared/MilkdownEditor.tsx index fdbefb83..f9f2d149 100644 --- a/frontend/src/shared/MilkdownEditor.tsx +++ b/frontend/src/shared/MilkdownEditor.tsx @@ -1,4 +1,9 @@ import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { IconButton } from '@primer/react' +import { BoldIcon, CodeIcon, ItalicIcon, StrikethroughIcon } from '@primer/octicons-react' +import type { SelectionToolbarAction, SelectionToolbarHandle, SelectionToolbarState } from './milkdownCore' import styles from './MilkdownEditor.module.css' export interface MilkdownEditorProps { @@ -48,6 +53,11 @@ export function MilkdownEditor({ value, onChange, ariaLabel, placeholder, testId const containerRef = useRef(null) const editable = onChange !== undefined const [core, setCore] = useState(null) + // The floating selection toolbar's live handle (goal 0253) -- set + // once the engine mounts an EDITABLE instance, null otherwise; the + // React buttons render into its body-level element via portal. + const [toolbar, setToolbar] = useState(null) + const [toolbarState, setToolbarState] = useState({ bold: false, italic: false, strikethrough: false, code: false }) // The doc's own draft, updated on every keystroke (markdownUpdated) // and read directly by the caller's own commit -- never round- // tripped through React state first (testing.md). Refreshed via an @@ -88,6 +98,15 @@ export function MilkdownEditor({ value, onChange, ariaLabel, placeholder, testId // own $remark extension point, applied here rather than baked into // NOTE_FEATURES since it isn't a Crepe feature flag. crepe.editor.use(disableIndentedCodeBlock) + // The floating selection toolbar (goal 0253): editable mounts + // only -- a readonly display has no selection to format. Wired + // before create(), like every plugin registration. + let toolbarHandle: SelectionToolbarHandle | null = null + if (editable) { + toolbarHandle = core.attachSelectionToolbar(crepe, styles.selectionToolbar) + toolbarHandle.onState(setToolbarState) + setToolbar(toolbarHandle) + } crepe.on((listener) => { listener.markdownUpdated((_ctx, markdown) => { onChangeRef.current?.(markdown) @@ -115,7 +134,11 @@ export function MilkdownEditor({ value, onChange, ariaLabel, placeholder, testId return () => { destroyed = true onReadyRef.current?.(undefined) - void ready.finally(() => crepe.destroy()) + setToolbar(null) + void ready.finally(() => { + toolbarHandle?.destroy() + return crepe.destroy() + }) } // value/ariaLabel/placeholder deliberately excluded: every caller // remounts this component fresh for a new edit session or a new @@ -141,6 +164,39 @@ export function MilkdownEditor({ value, onChange, ariaLabel, placeholder, testId ) : (
)} + {toolbar && createPortal(, toolbar.contentEl)}
) } + +// The floating toolbar's buttons -- portaled into the body-level +// element the kit's TooltipProvider positions (goal 0253). Pointer- +// down is prevented so pressing a button never steals the editor's +// focus/selection out from under the very command it dispatches (the +// selection-toolbar convention Crepe's own buttons also follow). +function SelectionToolbarButtons({ toolbar, state }: { toolbar: SelectionToolbarHandle; state: SelectionToolbarState }) { + const { t } = useTranslation('common') + const items: { action: SelectionToolbarAction; icon: typeof BoldIcon; label: string }[] = [ + { action: 'bold', icon: BoldIcon, label: t('formattingToolbar.bold') }, + { action: 'italic', icon: ItalicIcon, label: t('formattingToolbar.italic') }, + { action: 'strikethrough', icon: StrikethroughIcon, label: t('formattingToolbar.strikethrough') }, + { action: 'code', icon: CodeIcon, label: t('formattingToolbar.code') }, + ] + return ( + <> + {items.map(({ action, icon, label }) => ( + e.preventDefault()} + onClick={() => toolbar.run(action)} + /> + ))} + + ) +} diff --git a/frontend/src/shared/milkdownCore.ts b/frontend/src/shared/milkdownCore.ts index e9c9a6e3..4f0ef8dc 100644 --- a/frontend/src/shared/milkdownCore.ts +++ b/frontend/src/shared/milkdownCore.ts @@ -8,6 +8,19 @@ import { Crepe } from '@milkdown/crepe' // any literal color. import '@milkdown/crepe/theme/common/style.css' import { $remark } from '@milkdown/utils' +import { tooltipFactory, TooltipProvider } from '@milkdown/kit/plugin/tooltip' +import { commandsCtx } from '@milkdown/kit/core' +import { + isMarkSelectedCommand, + toggleEmphasisCommand, + toggleInlineCodeCommand, + toggleStrongCommand, + strongSchema, + emphasisSchema, + inlineCodeSchema, +} from '@milkdown/kit/preset/commonmark' +import { toggleStrikethroughCommand, strikethroughSchema } from '@milkdown/kit/preset/gfm' +import type { EditorView } from '@milkdown/kit/prose/view' // The Milkdown machinery behind shared/MilkdownEditor.tsx -- reachable // only through that component's dynamic import (goal 0244 S3), so the @@ -30,6 +43,10 @@ export const NOTE_FEATURES = { [Crepe.Feature.Latex]: false, [Crepe.Feature.BlockEdit]: false, [Crepe.Feature.CodeMirror]: false, + // Crepe's own selection toolbar is replaced by Mill's floating one + // (goal 0253, attachSelectionToolbar below) -- Crepe mounts its + // toolbar inside the editor node, where a canvas note clips it. + [Crepe.Feature.Toolbar]: false, } // Disables CommonMark's INDENTED code block construct (a bare line @@ -53,3 +70,122 @@ export const disableIndentedCodeBlock = $remark('disableIndentedCodeBlock', () = extensions.push({ disable: { null: ['codeIndented'] } }) } }) + +// --- The selection toolbar (goal 0253) --- +// +// Crepe's own toolbar feature is OFF (NOTE_FEATURES below): it mounts +// its content through TooltipProvider's default parent -- inside the +// editor's own node -- where a canvas note clips it, the board's zoom +// transform shrinks it, and floating-ui misplaces it (absolute +// positioning inside a transformed ancestor). Its config exposes no +// mount point, so Mill registers its OWN selection toolbar through the +// same kit primitives, with the provider's documented `root: +// document.body` -- body is untransformed, so the toolbar floats at UI +// scale beside the selection regardless of board zoom, and nothing +// ever clips it. The toolbar CONTENT stays framework-owned: the host +// (MilkdownEditor.tsx) portals Mill's React buttons into `contentEl`; +// this module only owns registration, positioning, active-mark state, +// and the command calls. + +export type SelectionToolbarAction = 'bold' | 'italic' | 'strikethrough' | 'code' + +export interface SelectionToolbarState { + bold: boolean + italic: boolean + strikethrough: boolean + code: boolean +} + +export interface SelectionToolbarHandle { + // The floating element Mill's React toolbar portals into. Appended + // to document.body by the provider on first update; removed on + // destroy. + contentEl: HTMLElement + run: (action: SelectionToolbarAction) => void + onState: (cb: (state: SelectionToolbarState) => void) => void + destroy: () => void +} + +const millSelectionToolbar = tooltipFactory('MILL_SELECTION_TOOLBAR') + +// attachSelectionToolbar wires the tooltip plugin onto a Crepe editor. +// Must run BEFORE crepe.create() (plugins register at create); the +// returned handle stays valid for the editor's whole life. +export function attachSelectionToolbar(crepe: Crepe, className: string): SelectionToolbarHandle { + const contentEl = document.createElement('div') + contentEl.className = className + contentEl.dataset.testid = 'milkdown-selection-toolbar' + // The marker outside-press commit listeners key on (goal 0253): the + // toolbar floats at body level, OUTSIDE any editor wrapper, yet a + // press on it is part of the edit session -- hosts exclude + // [data-milkdown-selection-toolbar] from their own commit-on- + // outside-press logic (AtlasStickyNode's document listener). + contentEl.setAttribute('data-milkdown-selection-toolbar', '') + let stateCb: ((state: SelectionToolbarState) => void) | null = null + let provider: TooltipProvider | null = null + + const readState = (): SelectionToolbarState => + crepe.editor.action((ctx) => { + const commands = ctx.get(commandsCtx) + return { + bold: commands.call(isMarkSelectedCommand.key, strongSchema.type(ctx)), + italic: commands.call(isMarkSelectedCommand.key, emphasisSchema.type(ctx)), + strikethrough: commands.call(isMarkSelectedCommand.key, strikethroughSchema.type(ctx)), + code: commands.call(isMarkSelectedCommand.key, inlineCodeSchema.type(ctx)), + } + }) + + class MillToolbarView { + constructor(view: EditorView) { + provider = new TooltipProvider({ + content: contentEl, + root: document.body, + debounce: 20, + offset: 8, + }) + provider.onShow = () => stateCb?.(readState()) + this.update(view) + } + + update = (view: EditorView, prevState?: Parameters[1]) => { + provider?.update(view, prevState) + if (contentEl.dataset.show === 'true') stateCb?.(readState()) + } + + destroy = () => { + provider?.destroy() + contentEl.remove() + } + } + + crepe.editor.use(millSelectionToolbar) + crepe.editor.config((ctx) => { + ctx.set(millSelectionToolbar.key, { + view: (view: EditorView) => new MillToolbarView(view), + }) + }) + + const COMMANDS = { + bold: toggleStrongCommand, + italic: toggleEmphasisCommand, + strikethrough: toggleStrikethroughCommand, + code: toggleInlineCodeCommand, + } as const + + return { + contentEl, + run: (action) => { + crepe.editor.action((ctx) => { + ctx.get(commandsCtx).call(COMMANDS[action].key) + }) + stateCb?.(readState()) + }, + onState: (cb) => { + stateCb = cb + }, + destroy: () => { + provider?.destroy() + contentEl.remove() + }, + } +} diff --git a/userdocs/concepts/atlas.md b/userdocs/concepts/atlas.md index c9f546d4..10df7aac 100644 --- a/userdocs/concepts/atlas.md +++ b/userdocs/concepts/atlas.md @@ -39,7 +39,9 @@ connected by links, grouped into areas you can drill into. - **Notes are markdown.** Write headings, lists, bold, tables — in a card's note or a board sticky note (press N and click). While you type, formatting appears in place and the markdown syntax - fades on every line except the one you're editing; at rest the + fades on every line except the one you're editing; select some + text and a small toolbar floats beside it with bold, italic, + strikethrough, and code; at rest the note shows the rendered result, and clicking it brings the source back. A long sticky note scrolls in place, and grows while you edit it — and any note opens big: ⌘-click it (or right-click → diff --git a/userdocs/llms-full.txt b/userdocs/llms-full.txt index efd1521f..c0dbb256 100644 --- a/userdocs/llms-full.txt +++ b/userdocs/llms-full.txt @@ -349,7 +349,9 @@ connected by links, grouped into areas you can drill into. - **Notes are markdown.** Write headings, lists, bold, tables — in a card's note or a board sticky note (press N and click). While you type, formatting appears in place and the markdown syntax - fades on every line except the one you're editing; at rest the + fades on every line except the one you're editing; select some + text and a small toolbar floats beside it with bold, italic, + strikethrough, and code; at rest the note shows the rendered result, and clicking it brings the source back. A long sticky note scrolls in place, and grows while you edit it — and any note opens big: ⌘-click it (or right-click →