From ff83c8c8a2ba54929e06cb2f3fb1fc12e506cdae Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Sun, 30 Aug 2026 14:45:15 -0400 Subject: [PATCH] feat: typing [x] at a note line start creates a to-do, and Enter continues the list unchecked (goal 0254) The engine's own task rule only fires inside an existing list item, so the converged line-start syntax ([] / [ ] / [x] ) did nothing in a plain paragraph. Mill adds one input rule through the engine's own extension seam: at a paragraph start it wraps the line into a task item with the matching state; inside a list item it re-marks (the stock rule can't touch [] or an item that is already a to-do). Enter at the end of a checked to-do now splits with the NEW item born unchecked -- intercepted at the view level so the state never changes after the item mounts (a post-split attr flip was measured to remount the checkbox widget under the caret and drop in-flight keystrokes). The formatting-affordance e2e cases split into their own spec along the 500-line seam. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --- frontend/e2e/atlas-note-formatting.spec.ts | 162 +++++++++++++++++++++ frontend/e2e/atlas-note-markdown.spec.ts | 55 ------- frontend/src/shared/MilkdownEditor.tsx | 6 +- frontend/src/shared/milkdownCore.ts | 81 ++++++++++- userdocs/concepts/atlas.md | 3 +- userdocs/llms-full.txt | 3 +- 6 files changed, 250 insertions(+), 60 deletions(-) create mode 100644 frontend/e2e/atlas-note-formatting.spec.ts diff --git a/frontend/e2e/atlas-note-formatting.spec.ts b/frontend/e2e/atlas-note-formatting.spec.ts new file mode 100644 index 00000000..1617f9d9 --- /dev/null +++ b/frontend/e2e/atlas-note-formatting.spec.ts @@ -0,0 +1,162 @@ +import { test, expect } from './fixtures/server' +import { deleteSticky } from './fixtures/atlasBoard' +import { placeNoteClear } from './fixtures/atlasEmptyRegion' +import { blurSticky, fillSticky, stickyEditor } from './fixtures/codeEditor' + +// The note editor's formatting affordances (split out of +// atlas-note-markdown.spec.ts along the 500-line seam): the floating +// selection toolbar (goal 0253) and the line-start to-do shortcut + +// unchecked Enter-continuation (goal 0254). Shared worker pool: every +// note created here is deleted here. + +// 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) +}) + +// Regression (goal 0254): typing `[x] `/`[ ] `/`[] ` at the START of +// a plain note line creates a to-do -- the engine's own task rule +// only fires inside an existing list item, so the converged +// line-start convention did nothing (and the two-step `- ` path was +// the only, undiscoverable, way in). Also pinned: Enter at the end of +// a checked to-do continues UNCHECKED (the engine's split inherits +// checked: true; every converged to-do surface starts the next item +// unchecked). Caret-settle guards as in the task test above: each +// conversion/split relocates the caret asynchronously. +test('typing [x] at a line start creates a checked to-do, Enter continues unchecked, and both survive commit', 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() + const editable = stickyEditor(page).locator('[contenteditable="true"]') + // The engine mounts async (lazy chunk + focus hop): a keystroke + // fired before real focus lands is silently dropped -- poll the one + // observable (activeElement) before the first character. + await expect + .poll(() => page.evaluate(() => document.activeElement?.getAttribute('contenteditable') === 'true')) + .toBe(true) + + // Settled = the caret sits in a list item AND that item's DOM node + // is the SAME element across two consecutive polls -- a conversion + // or attr flip remounts the item's checkbox widget asynchronously, + // and keystrokes fired mid-remount are dropped (the engine trait the + // stock task test above documents with its own recovery loop). + const caretSettledInListItem = () => + expect + .poll(() => page.evaluate(() => { + const anchor = document.getSelection()?.anchorNode + const el = anchor instanceof Element ? anchor : anchor?.parentElement + const li = el?.closest('li') + if (!li) return 'no-li' + const w = window as unknown as { __liSettleRef?: Element } + const prev = w.__liSettleRef + w.__liSettleRef = li + return prev === li ? 'stable' : 'changed' + })) + .toBe('stable') + + // The same bounded retype-recovery the stock task test above uses: + // a conversion/flip can remount the item's widget AFTER any settle + // observable this harness can poll, and keystrokes in that window + // drop (partially or wholly) -- a real user's recovery is selecting + // the line and retyping, performed here before the hard assertion. + const typeIntoItem = async (text: string) => { + for (let round = 0; round < 4; round++) { + await page.keyboard.type(text, { delay: 20 }) + try { + await editable.getByText(text).waitFor({ state: 'visible', timeout: 2_000 }) + return + } catch { + // Under load the caret can ESCAPE the item mid-remount, so a + // blind retype lands in the void -- re-anchor by clicking the + // item itself, then clear whatever partially landed. + await editable.locator('li').last().click() + await page.keyboard.press('End') + await page.keyboard.press('Shift+Home') + await page.keyboard.press('Backspace') + } + } + await expect(editable).toContainText(text) + } + + await page.keyboard.type('[x] ', { delay: 40 }) + await expect(editable.locator('.milkdown-icon.label.checked')).toHaveCount(1) + await caretSettledInListItem() + await typeIntoItem('buy milk') + + // Enter continues as an UNCHECKED to-do (the converged convention; + // the raw engine split inherits checked: true) -- so the next line + // is just typed, no brackets needed. Settle first: an Enter fired + // while the caret is mid-relocation bypasses the task-aware split. + await caretSettledInListItem() + await page.keyboard.press('Enter') + await expect(editable.locator('.milkdown-icon.label.unchecked')).toHaveCount(1) + await caretSettledInListItem() + await typeIntoItem('call the bank') + + await expect(editable).toContainText('buy milk') + await expect(editable).toContainText('call the bank') + await expect(editable).not.toContainText('[') + + await blurSticky(page) + const note = page.getByTestId('atlas-sticky-note').filter({ hasText: 'buy milk' }) + await expect(note.locator('.milkdown-icon.label.checked')).toHaveCount(1) + await expect(note.locator('.milkdown-icon.label.unchecked')).toHaveCount(1) + await expect(note).not.toContainText('[') + + await deleteSticky(page, note) +}) diff --git a/frontend/e2e/atlas-note-markdown.spec.ts b/frontend/e2e/atlas-note-markdown.spec.ts index 462ec372..e318a9e2 100644 --- a/frontend/e2e/atlas-note-markdown.spec.ts +++ b/frontend/e2e/atlas-note-markdown.spec.ts @@ -385,58 +385,3 @@ 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/shared/MilkdownEditor.tsx b/frontend/src/shared/MilkdownEditor.tsx index f9f2d149..d61c3a7d 100644 --- a/frontend/src/shared/MilkdownEditor.tsx +++ b/frontend/src/shared/MilkdownEditor.tsx @@ -85,7 +85,7 @@ export function MilkdownEditor({ value, onChange, ariaLabel, placeholder, testId useEffect(() => { if (!core || !containerRef.current) return - const { Crepe, NOTE_FEATURES, disableIndentedCodeBlock } = core + const { Crepe, NOTE_FEATURES, disableIndentedCodeBlock, taskAtLineStart, configureTaskEnter } = core const crepe = new Crepe({ root: containerRef.current, defaultValue: value, @@ -98,6 +98,10 @@ 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) + // Line-start to-do shortcut + unchecked Enter-continuation (goal + // 0254) -- same registration seam. + crepe.editor.use(taskAtLineStart) + configureTaskEnter(crepe) // The floating selection toolbar (goal 0253): editable mounts // only -- a readonly display has no selection to format. Wired // before create(), like every plugin registration. diff --git a/frontend/src/shared/milkdownCore.ts b/frontend/src/shared/milkdownCore.ts index 4f0ef8dc..20b22c69 100644 --- a/frontend/src/shared/milkdownCore.ts +++ b/frontend/src/shared/milkdownCore.ts @@ -7,9 +7,12 @@ import { Crepe } from '@milkdown/crepe' // (both unused -- the AI feature stays off, see the note below) carry // any literal color. import '@milkdown/crepe/theme/common/style.css' -import { $remark } from '@milkdown/utils' +import { $inputRule, $remark } from '@milkdown/utils' +import { InputRule } from '@milkdown/kit/prose/inputrules' +import { findWrapping } from '@milkdown/kit/prose/transform' +import { splitListItem } from '@milkdown/kit/prose/schema-list' import { tooltipFactory, TooltipProvider } from '@milkdown/kit/plugin/tooltip' -import { commandsCtx } from '@milkdown/kit/core' +import { commandsCtx, editorViewOptionsCtx } from '@milkdown/kit/core' import { isMarkSelectedCommand, toggleEmphasisCommand, @@ -18,6 +21,7 @@ import { strongSchema, emphasisSchema, inlineCodeSchema, + bulletListSchema, } from '@milkdown/kit/preset/commonmark' import { toggleStrikethroughCommand, strikethroughSchema } from '@milkdown/kit/preset/gfm' import type { EditorView } from '@milkdown/kit/prose/view' @@ -71,6 +75,79 @@ export const disableIndentedCodeBlock = $remark('disableIndentedCodeBlock', () = } }) +// Typing `[] `, `[ ] `, or `[x] ` at the START of a plain paragraph +// creates a to-do (goal 0254) -- the converged line-start convention +// of the WYSIWYG family this editor adopted into. The engine's OWN +// task rule (preset-gfm's wrapInTaskListInputRule) deliberately only +// fires INSIDE an existing list item; this rule owns exactly the +// complementary case and bails inside a list item, so one keystroke +// can never match both. Registered through $inputRule, the same +// extension seam disableIndentedCodeBlock uses. +export const taskAtLineStart = $inputRule((ctx) => { + return new InputRule(/^\[(?[xX ])?\]\s$/, (state, match, start, end) => { + const $start = state.doc.resolve(start) + if ($start.parent.type.name !== 'paragraph') return null + const checked = (match.groups?.checked ?? '').toLowerCase() === 'x' + if ($start.depth >= 2 && $start.node(-1).type.name === 'list_item') { + // Inside a list item the stock rule runs first and handles the + // plain-bullet case; it structurally can't handle `[] ` (its + // regex requires a character between the brackets) or an item + // that is ALREADY a to-do (it refuses checked != null -- and + // Enter-continuation inherits the previous item's checked + // state, so re-marking is the everyday follow-up-line case). + const li = $start.node(-1) + const tr = state.tr.deleteRange(start, end) + tr.setNodeMarkup($start.before($start.depth - 1), undefined, { ...li.attrs, checked }) + return tr.scrollIntoView() + } + const tr = state.tr.deleteRange(start, end) + const range = tr.doc.resolve(start).blockRange() + if (!range) return null + const wrapping = findWrapping(range, bulletListSchema.type(ctx)) + if (!wrapping) return null + tr.wrap(range, wrapping) + // The wrap inserted `wrapping.length` opening tokens before the + // paragraph; the freshly-created list item sits one position + // inside the outermost wrapper. + const liPos = range.start + wrapping.length - 1 + const li = tr.doc.nodeAt(liPos) + if (!li || li.type.name !== 'list_item') return null + tr.setNodeMarkup(liPos, undefined, { ...li.attrs, checked }) + return tr.scrollIntoView() + }) +}) + +// Pressing Enter at the end of a CHECKED to-do continues with another +// CHECKED one -- the engine's list split copies the item's attrs, +// where every converged to-do surface starts the next item unchecked. +// Intercepted at the VIEW level (editorViewOptionsCtx's direct props +// run before every plugin keymap) so the new item is BORN unchecked +// in the split's own single transaction -- a post-split attr flip was +// measured live to remount the item's checkbox widget under the caret +// and drop in-flight keystrokes, so the state must never change after +// the item mounts. Enter on an EMPTY to-do falls through to the +// default (exit the list), and modified Enters are untouched. +export function configureTaskEnter(crepe: Crepe): void { + crepe.editor.config((ctx) => { + ctx.update(editorViewOptionsCtx, (prev) => ({ + ...prev, + handleKeyDown: (view, event) => { + if (event.key === 'Enter' && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) { + const { $from } = view.state.selection + if ($from.depth >= 2) { + const li = $from.node(-1) + if (li.type.name === 'list_item' && li.attrs.checked === true && li.textContent !== '') { + if (splitListItem(li.type, { ...li.attrs, checked: false })(view.state, view.dispatch)) return true + } + } + } + const prevHandler = prev.handleKeyDown + return typeof prevHandler === 'function' ? (prevHandler.call(view, view, event) ?? false) : false + }, + })) + }) +} + // --- The selection toolbar (goal 0253) --- // // Crepe's own toolbar feature is OFF (NOTE_FEATURES below): it mounts diff --git a/userdocs/concepts/atlas.md b/userdocs/concepts/atlas.md index 10df7aac..e6f2a965 100644 --- a/userdocs/concepts/atlas.md +++ b/userdocs/concepts/atlas.md @@ -41,7 +41,8 @@ connected by links, grouped into areas you can drill into. you type, formatting appears in place and the markdown syntax 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 + strikethrough, and code; type `[]` or `[x]` at the start of a line + for a to-do, and Enter continues the list unchecked; 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 c0dbb256..9d59c6ef 100644 --- a/userdocs/llms-full.txt +++ b/userdocs/llms-full.txt @@ -351,7 +351,8 @@ connected by links, grouped into areas you can drill into. you type, formatting appears in place and the markdown syntax 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 + strikethrough, and code; type `[]` or `[x]` at the start of a line + for a to-do, and Enter continues the list unchecked; 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 →