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
162 changes: 162 additions & 0 deletions frontend/e2e/atlas-note-formatting.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <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)
})

// 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)
})
55 changes: 0 additions & 55 deletions frontend/e2e/atlas-note-markdown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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)
})
6 changes: 5 additions & 1 deletion frontend/src/shared/MilkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
81 changes: 79 additions & 2 deletions frontend/src/shared/milkdownCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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(/^\[(?<checked>[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
Expand Down
3 changes: 2 additions & 1 deletion userdocs/concepts/atlas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand Down
3 changes: 2 additions & 1 deletion userdocs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand Down
Loading