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
56 changes: 56 additions & 0 deletions frontend/e2e/atlas-note-markdown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <strong>.
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)
})
5 changes: 5 additions & 0 deletions frontend/src/atlas/AtlasStickyNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,11 @@
"deleteRow": "Delete row",
"booleanTrue": "Yes",
"booleanFalse": "No"
},
"formattingToolbar": {
"bold": "Bold",
"italic": "Italic",
"strikethrough": "Strikethrough",
"code": "Code"
}
}
26 changes: 26 additions & 0 deletions frontend/src/shared/MilkdownEditor.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
58 changes: 57 additions & 1 deletion frontend/src/shared/MilkdownEditor.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -48,6 +53,11 @@ export function MilkdownEditor({ value, onChange, ariaLabel, placeholder, testId
const containerRef = useRef<HTMLDivElement | null>(null)
const editable = onChange !== undefined
const [core, setCore] = useState<CoreModule | null>(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<SelectionToolbarHandle | null>(null)
const [toolbarState, setToolbarState] = useState<SelectionToolbarState>({ 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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -141,6 +164,39 @@ export function MilkdownEditor({ value, onChange, ariaLabel, placeholder, testId
) : (
<div ref={containerRef} className={styles.mount} />
)}
{toolbar && createPortal(<SelectionToolbarButtons toolbar={toolbar} state={toolbarState} />, toolbar.contentEl)}
</div>
)
}

// 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 }) => (
<IconButton
key={action}
icon={icon}
size="small"
variant="invisible"
aria-label={label}
aria-pressed={state[action]}
data-testid={`milkdown-toolbar-${action}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toolbar.run(action)}
/>
))}
</>
)
}
136 changes: 136 additions & 0 deletions frontend/src/shared/milkdownCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<TooltipProvider['update']>[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()
},
}
}
4 changes: 3 additions & 1 deletion userdocs/concepts/atlas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand Down
4 changes: 3 additions & 1 deletion userdocs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand Down
Loading