diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4deecb3..b208d98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,33 @@ jobs: test -f dist/app/index.html || { echo "::error::dist/app/index.html missing — app shell gone"; exit 1; } test -f dist/404.html || { echo "::error::dist/404.html missing — SPA deep links would 404"; exit 1; } grep -q "Read code. Annotate it." dist/index.html || { echo "::error::dist/index.html is not the landing page"; exit 1; } + + # In a real browser against the dev server: ink that stays on its line while the document + # scrolls, and the flows that cross the editor, the database and the UI. A separate job so + # that `verify` stays fast and its result arrives first. Not in deploy.yml: a push to main + # has already passed this in review. + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: End to end + run: npm run test:e2e + + - name: Keep the traces of anything that failed + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 14ab128..f92a698 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ .vite/ *.local .DS_Store +/test-results/ +/playwright-report/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3da3adf..94e5e1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,7 @@ Other scripts: ```bash npm run typecheck # tsc --noEmit npm test # vitest +npm run test:e2e # playwright, in a real browser (first run: npx playwright install chromium) npm run build # typecheck + production build ``` @@ -66,7 +67,8 @@ npm run build # typecheck + production build npm run typecheck && npm test && npm run build ``` -CI runs exactly these, so running them locally is the fastest way to find out. +CI runs exactly these, plus the end-to-end suite in a separate job, so running them locally +is the fastest way to find out. ### Tests @@ -78,6 +80,14 @@ assertion per claim, and a name that states the claim rather than the mechanism. Anything touching anchoring deserves more tests than you think. Getting it wrong misplaces somebody's handwriting. +### End to end + +`e2e/` drives the app in Chromium against the dev server. Each test gets a fresh browser +context, seeds IndexedDB with exactly what it needs through the helpers in `e2e/app.ts`, and +opens it from the recents list - no network, no GitHub. Add one when a change crosses the +editor, the database and the UI in a way Vitest cannot see; `e2e/ink-scroll.spec.ts` is the +one that guards the plan's highest risk, ink drifting off its line. + ### Things CI cannot check Stylus feel and ink/scroll behaviour have no substitute for a real device. If your change diff --git a/PHASE_4_ORGANISATION.md b/PHASE_4_ORGANISATION.md new file mode 100644 index 0000000..7d4e0c1 --- /dev/null +++ b/PHASE_4_ORGANISATION.md @@ -0,0 +1,189 @@ +# Phase 4 — Organisation + +*Companion to `IMPLEMENTATION_PLAN.md` §4, §7, §11. That document says what Phase 4 is — +layers, bookmarks, typed notes, an annotation index. This one says how, against the code +that exists after Phase 3, and records the calls that were made without a conversation so +they can be reversed cheaply if they are wrong.* + +**Done when** (from the plan): *you can find a note you wrote last week by typing three +words.* + +--- + +## 0. The one idea + +Nothing in this phase needs a new coordinate system, a new store in IndexedDB, or a schema +bump. The data model in §4 already has `Layer`, `Bookmark`, `kind: 'note'` and +`Annotation.layerId`; Phases 2 and 3 just never exercised them. Phase 4 is UI over records +that already exist, plus three rules about how those records move: + +1. **A typed note is an annotation like a stroke.** It sits in the same `placed` list, so it + is carried across an edit by the same `lineMapper`, re-anchored by the same save, and + lands in the same displaced tray when the ladder gives up. No second anchoring path. +2. **A bookmark is anchored, not numbered.** It carries an `Anchor` already. It resolves + through `resolveAnchor` when its file opens and moves with edits exactly as ink does. +3. **Visibility is a render-time filter, never a reload.** Hiding a layer repaints; it does + not touch the database or the undo stacks. + +--- + +## 1. Layers + +### Where they live in the UI + +In the palette's `⋯` overflow, which §7 already reserved for "Layers, Code Space toggle, +export". Today `⋯` collapses the palette; that moves into the same menu as "Hide tools". + +``` +┌───────────────────────────┐ +│ Layers │ +│ ◉ 👁 Notes 12 │ ← active: new ink and notes go here +│ ○ 👁 Questions 3 │ +│ ○ ⌀ Exam revision 0 │ ← hidden +│ + New layer │ +├───────────────────────────┤ +│ Export annotations │ +│ Hide tools │ +└───────────────────────────┘ +``` + +### Rules + +- **One active layer per source**, persisted in `meta` as `activeLayer:`. Every + new stroke and note is written into it. `ensureDefaultLayer` stays — a source with no + layers still gets one, silently, the first time it is opened. +- **Hidden layers are not drawn, not erasable, and their notes are not shown.** The eraser + hit-tests only what is visible, because erasing ink you cannot see is a destructive act + with no feedback. +- **Drawing on a hidden active layer shows it.** The alternatives — forbidding the hide, or + silently drawing invisible ink — are both worse than the layer reappearing under the pen. +- **Rename** in place. **Delete** asks once, inline (never `window.confirm`), and offers to + *move* the layer's annotations into another layer instead of deleting them. That is the + plan's "reassign": it works at layer granularity because selecting individual strokes + needs a lasso, which §13 defers. +- **The last layer cannot be deleted.** Every annotation needs a home. +- Deleting or merging a layer **clears the ink undo stack** for the open file. An undo entry + holds whole annotations, and replaying one into a layer that no longer exists would + resurrect it somewhere the user just removed. + +--- + +## 2. Bookmarks + +- **Toggle from the toolbar** (a ribbon button), and from a narrow **bookmark gutter** left + of the line numbers — click a line's gutter to set or clear a bookmark there. The gutter is + the tablet-friendly path; the button is the obvious one. +- **Which line the button means:** the cursor's line when it is on screen, otherwise the top + line of the viewport. A reader who scrolled without clicking means "here", not "line 1". +- **Default name** is the bookmarked line's own text, trimmed and clipped — `export async + function runJob(job) {` is a better label than "Bookmark 3", and costs the user nothing. + Rename inline in the list. Colour from the palette's swatches; amber by default (the + swatch list already calls it "bookmark amber"). +- **Resolved when their file opens**, against the text as it stands. An unresolved bookmark + is shown in the list with a warning rather than pointed at a line it no longer describes. +- **Carried across edits** with the same line mapper as ink, and re-anchored on save. +- `Bookmark` gains an optional `updatedAt`. §3 of the plan says every record has one; this + one was missed, and Phase 6 sync will need it. + +--- + +## 3. Typed notes + +- **A fourth palette tool, "Note."** Arm it, tap a line, type. Tapping an existing note + with the tool armed — or with no tool armed at all — opens it for editing. Keeping note + creation in the palette means it works the same way with a stylus as everything else. +- **Rendered at the end of their line**, as a CodeMirror widget decoration: a quiet chip, + one line, clipped, with the note's colour as a hairline on its left edge. Because it is a + CodeMirror decoration it scrolls *with* the text by construction — there is nothing to + keep in sync. No line wrapping is configured, so a chip never changes a line's height and + ink anchored nearby is unaffected. +- **Edited in a small card** that opens next to the line: a textarea, *Done*, *Delete*. + `Escape` cancels, `Mod-Enter` saves. Saving an empty note deletes it. +- Notes are **not** on the ink undo stack. Undo there means "the last stroke", and a note + has its own explicit delete. +- The displaced tray shows a note's **text**, not just the code it was written against — + for a typed note the words are the thing worth keeping. + +--- + +## 4. Annotation index + +- The sidebar gets three views: **Files**, **Bookmarks**, **Search**. A PDF reader's + sidebar works the same way, and it keeps the toolbar from growing. +- **Search** covers every note and bookmark in the open source, whichever file it is in. + Every word typed must appear (any order, case- and accent-insensitive); the file path + counts towards a match, so `runner webcontainer` finds the WebContainer note in + `runner.ts`. With nothing typed, the view *is* the index: every note, grouped by file. +- **Click to jump.** Jumping opens the file if needed and centres the target line. For a + note, the line is corrected after the file opens to wherever the anchor ladder actually + placed it, so a note in a file edited elsewhere is still found. +- Notes in **hidden layers are still searchable**, and say so. Search is how you find + things you are not currently looking at. +- Matching is pure and synchronous over strings (`src/organise/search.ts`) and tested + table-style, like the anchor ladder. + +--- + +## 5. Navigation + +One function, `goTo(fileId, line)`, owned by the session store and used by bookmarks, +search, and anything later. It reuses the reading-position plumbing (`pendingLine`) but +asks CodeView to **centre** the line and put the cursor on it, so the active-line highlight +shows where you landed. Restoring a reading position still lands at the top, as before. + +--- + +## 6. Export + +`exportSource()` has existed since Phase 0 with nothing calling it. It gets a menu item: +one JSON file with the source, its files, layers, annotations and bookmarks. The plan calls +it the backstop for every storage risk; it should not stay unreachable. Import is not in +this phase. + +--- + +## 7. Commit order + +1. This document. +2. `goTo` and line centring — the shared navigation primitive. +3. Layers: data helpers, store, ink integration, the overflow menu. +4. Export. +5. Bookmarks: store, gutter, toolbar toggle, sidebar views. +6. Typed notes: the tool, the widgets, the card, the tray. +7. Search: the matcher and the sidebar view. + +Each commit builds, typechecks and passes the suite on its own. + +--- + +## 8. Testing + +| What | How | +|---|---| +| Moving and counting a layer's annotations | Vitest + fake-indexeddb, alongside `db.test.ts` | +| Which annotations are visible / erasable | Pure function, table-driven | +| Bookmark default names, resolution, shifting | Pure functions over `Anchor` | +| Search matching, ranking, snippets | Pure functions, table-driven | +| The whole flow | Headless Chrome against the dev server: draw, add a note, hide its layer, bookmark a line, search three words, jump | + +Stylus feel is unchanged by this phase — no ink path is touched except the visibility +filter — but the note tool is new input handling and should be tried on a tablet. + +--- + +## 9. Decided without asking — reverse if wrong + +| Call | Why | Cheapest reversal | +|---|---|---| +| Layers live in the palette overflow, not the toolbar | §7 reserved the overflow for them; the toolbar stays minimal | Move the panel component | +| Note is a palette tool | Same gesture as every other annotation; works with a stylus | Replace with a toolbar button | +| Notes render at the end of their line | Free scroll sync; no Code Space needed | Phase 5 can move them into bands | +| Sidebar tabs instead of more toolbar buttons | PDF-reader convention; keeps the toolbar quiet | Swap the tab header for buttons | +| Hidden-layer notes still appear in search | Search is for finding what you are not looking at | One filter in the search view | + +## 10. Deliberately not in this phase + +- **Lasso / selection** of individual annotations — §13. Reassignment is per layer. +- **Layer reordering** by drag. `order` is stored; nothing reads it except list order. +- **Handwriting OCR** — §13. Typed notes are what search covers. +- **Import** of an exported file. diff --git a/e2e/app.ts b/e2e/app.ts new file mode 100644 index 0000000..cfb5774 --- /dev/null +++ b/e2e/app.ts @@ -0,0 +1,243 @@ +/** + * Shared set-up for the end-to-end tests. + * + * The demo codebase is a GitHub source whose files are already cached, so nothing touches + * the network: it is written straight into IndexedDB and then opened from the recents list, + * exactly as a returning reader would open it. + */ + +import { expect, type Page } from '@playwright/test'; + +export const SOURCE_ID = 'github:demo/repo@main'; + +const runner = Array.from({ length: 140 }, (_, i) => { + const lines: Record = { + 0: "import { getRuntime } from './runtime';", + 2: 'export async function runJob(job) {', + 3: ' const runtime = await getRuntime(job);', + 4: ' await runtime.prepare();', + 5: ' const result = await runtime.run();', + 6: ' return result;', + 7: '}', + 90: 'export function setupWebContainer(options) {', + 91: ' // boot the container once per tab', + 92: ' return boot(options);', + 93: '}', + }; + return lines[i] ?? `const filler${i} = ${i}; // line ${i + 1}`; +}).join('\n'); + +const util = Array.from({ length: 40 }, (_, i) => `def helper_${i}(x):\n return x * ${i}`).join('\n'); + +export const FILES = [ + { path: 'src/runner.ts', content: runner }, + { path: 'src/util.py', content: util }, + { path: 'README.md', content: '# demo\n\nA demo repository.\n' }, +]; + +export const fileId = (path: string) => `${SOURCE_ID}:${path}`; + +/** Seed the demo codebase and open it. */ +export async function openDemo(page: Page): Promise { + await page.goto('/app/'); + await expect(page.getByRole('heading', { name: 'Open a codebase' })).toBeVisible(); + + await page.evaluate( + async ({ sourceId, files }) => { + const db = await new Promise((resolve) => { + const req = indexedDB.open('annotatecode'); + req.onsuccess = () => resolve(req.result); + }); + const tx = db.transaction(['sources', 'files'], 'readwrite'); + const now = Date.now(); + tx.objectStore('sources').put({ + id: sourceId, + kind: 'github', + name: 'demo/repo', + origin: { owner: 'demo', repo: 'repo', ref: 'main' }, + createdAt: now, + lastOpenedAt: now, + }); + for (const f of files) { + tx.objectStore('files').put({ + id: `${sourceId}:${f.path}`, + sourceId, + path: f.path, + size: f.content.length, + content: f.content, + edited: false, + contentHash: 'x', + updatedAt: now, + }); + } + await new Promise((resolve) => (tx.oncomplete = resolve)); + db.close(); + }, + { sourceId: SOURCE_ID, files: FILES }, + ); + + await page.reload(); + await reopenRecent(page); +} + +/** Open the first codebase in the recents list. */ +export async function reopenRecent(page: Page): Promise { + await page.locator('.ac-recent').first().click(); + await expect(page.locator('.ac-tree .ac-row').first()).toBeVisible(); +} + +/** Open a file by path from the tree, expanding folders on the way. */ +export async function openFile(page: Page, path: string): Promise { + const segments = path.split('/'); + for (let i = 0; i < segments.length; i++) { + const at = segments.slice(0, i + 1).join('/'); + const row = page.locator(`.ac-row[title="${at}"]`); + const isOpenDir = await row.locator('.ac-caret.is-open').count(); + if (!isOpenDir) await row.click(); + } + await expect(page.locator('.ac-path')).toHaveText(path); + await expect(page.locator('.cm-content')).toBeVisible(); +} + +export interface LineBox { + top: number; + height: number; + /** Client x of the start of the code, right of the gutters. */ + left: number; +} + +/** Where a 1-based line is on screen. */ +export async function lineBox(page: Page, line: number): Promise { + const box = await page.evaluate((n) => { + const gutter = [...document.querySelectorAll('.cm-lineNumbers .cm-gutterElement')].find( + (e) => Number(e.textContent) === n, + ); + const content = document.querySelector('.cm-content')!.getBoundingClientRect(); + if (!gutter) return null; + const r = gutter.getBoundingClientRect(); + return { top: r.top, height: r.height, left: content.left }; + }, line); + if (!box) throw new Error(`line ${line} is not on screen`); + return box; +} + +/** Scroll so that a 1-based line is near the top. */ +export async function scrollToLine(page: Page, line: number): Promise { + await page.evaluate((n) => { + const scroller = document.querySelector('.cm-scroller')!; + const height = parseFloat(getComputedStyle(scroller).lineHeight) || 24.5; + scroller.scrollTop = Math.max(0, (n - 4) * height); + }, line); + await page.waitForTimeout(150); +} + +/** Draw a short horizontal zig-zag across a 1-based line with the armed tool. */ +export async function drawOnLine(page: Page, line: number, width = 120): Promise { + const box = await lineBox(page, line); + const y = box.top + box.height / 2; + const x = box.left + 40; + await page.mouse.move(x, y); + await page.mouse.down(); + for (let i = 1; i <= 10; i++) await page.mouse.move(x + (width * i) / 10, y + (i % 2 ? 3 : -3)); + await page.mouse.up(); + await page.waitForTimeout(150); +} + +/** Tap a 1-based line with the armed tool (the Note tool, usually). */ +export async function tapLine(page: Page, line: number): Promise { + const box = await lineBox(page, line); + await page.mouse.click(box.left + 60, box.top + box.height / 2); +} + +/** Every record in one object store. */ +export async function stored>(page: Page, store: string): Promise { + return page.evaluate(async (name) => { + const db = await new Promise((resolve) => { + const req = indexedDB.open('annotatecode'); + req.onsuccess = () => resolve(req.result); + }); + const all = await new Promise((resolve) => { + const req = db.transaction(name).objectStore(name).getAll(); + req.onsuccess = () => resolve(req.result); + }); + db.close(); + return all; + }, store) as Promise; +} + +/** Ink alpha painted on the pen canvas across one line's band. */ +export async function inkOn(page: Page, line: number): Promise { + const box = await lineBox(page, line); + return page.evaluate((b) => { + const canvas = document.querySelector( + '.ac-ink:not(.ac-ink-highlighter):not(.ac-ink-wet)', + )!; + const r = canvas.getBoundingClientRect(); + const scale = canvas.width / r.width; + const data = canvas + .getContext('2d')! + .getImageData( + Math.round((b.left - r.left) * scale), + Math.round((b.top - r.top) * scale), + Math.round(260 * scale), + Math.round(b.height * scale), + ).data; + let alpha = 0; + for (let i = 3; i < data.length; i += 4) alpha += data[i]!; + return alpha; + }, box); +} + +/** 1-based line numbers carrying a note chip, in order. */ +export async function chipLines(page: Page): Promise> { + return page.evaluate(() => { + const numbers = [...document.querySelectorAll('.cm-lineNumbers .cm-gutterElement')]; + return [...document.querySelectorAll('.ac-note-chip')].map((chip) => { + const r = chip.getBoundingClientRect(); + const y = r.top + r.height / 2; + const n = numbers.find((e) => { + const b = e.getBoundingClientRect(); + return b.height > 0 && y >= b.top && y < b.bottom; + }); + return { line: n ? Number(n.textContent) : null, text: chip.textContent ?? '' }; + }); + }); +} + +/** 1-based line numbers carrying a bookmark ribbon in the margin. */ +export async function ribbonLines(page: Page): Promise { + return page.evaluate(() => { + const numbers = [...document.querySelectorAll('.cm-lineNumbers .cm-gutterElement')]; + return [...document.querySelectorAll('.ac-ribbon')].flatMap((ribbon) => { + const y = ribbon.getBoundingClientRect().top + 4; + const n = numbers.find((e) => { + const b = e.getBoundingClientRect(); + return b.height > 0 && y >= b.top && y < b.bottom; + }); + return n ? [Number(n.textContent)] : []; + }); + }); +} + +/** Press a key with Ctrl (Cmd is the same binding in the app; Ctrl works everywhere here). */ +export async function withCtrl(page: Page, key: string): Promise { + await page.keyboard.press(`Control+${key}`); +} + +/** Write a note on a 1-based line with the Note tool, which must already be armed. */ +export async function writeNote(page: Page, line: number, text: string): Promise { + await tapLine(page, line); + await expect(page.locator('.ac-note-field')).toBeFocused(); + await page.keyboard.type(text); + await withCtrl(page, 'Enter'); + await expect(page.locator('.ac-note-card')).toHaveCount(0); +} + +/** Type above everything in the open file, in Edit mode, and save. */ +export async function insertAtTop(page: Page, text: string): Promise { + await page.getByRole('button', { name: 'Edit', exact: true }).click(); + const one = await lineBox(page, 1); + await page.mouse.click(one.left + 2, one.top + one.height / 2); + await page.keyboard.press('Home'); + await page.keyboard.type(text); +} diff --git a/e2e/bookmarks.spec.ts b/e2e/bookmarks.spec.ts new file mode 100644 index 0000000..c8e83f5 --- /dev/null +++ b/e2e/bookmarks.spec.ts @@ -0,0 +1,113 @@ +import { expect, test, type Page } from '@playwright/test'; +import { + insertAtTop, + lineBox, + openDemo, + openFile, + reopenRecent, + ribbonLines, + scrollToLine, + stored, + withCtrl, +} from './app'; + +interface StoredBookmark { + id: string; + name: string; + color: string; + line: number; +} + +async function clickMargin(page: Page, line: number) { + const box = await lineBox(page, line); + const margin = await page.locator('.ac-bookmark-gutter').boundingBox(); + await page.mouse.click(margin!.x + margin!.width / 2, box.top + box.height / 2); +} + +async function clickCode(page: Page, line: number) { + const box = await lineBox(page, line); + await page.mouse.click(box.left + 30, box.top + box.height / 2); +} + +test.beforeEach(async ({ page }) => { + await openDemo(page); + await openFile(page, 'src/runner.ts'); +}); + +test('the toolbar bookmarks the cursor line, named after its text', async ({ page }) => { + await clickCode(page, 3); + await page.getByRole('button', { name: 'Bookmark this line' }).click(); + + await expect.poll(() => ribbonLines(page)).toEqual([3]); + await expect + .poll(async () => (await stored(page, 'bookmarks'))[0]?.name) + .toBe('export async function runJob(job) {'); +}); + +test('scrolled away from the cursor, the toolbar means the top of the screen', async ({ page }) => { + await scrollToLine(page, 64); + const top = await page.$$eval('.cm-lineNumbers .cm-gutterElement', (els) => { + const box = document.querySelector('.cm-scroller')!.getBoundingClientRect(); + return Number( + els.find((e) => { + const r = e.getBoundingClientRect(); + return r.height > 0 && r.bottom > box.top + 1; + })?.textContent, + ); + }); + await page.getByRole('button', { name: 'Bookmark this line' }).click(); + + await expect + .poll(async () => { + const [bookmark] = await stored(page, 'bookmarks'); + return bookmark ? Math.abs(bookmark.line + 1 - top) : Infinity; + }) + .toBeLessThanOrEqual(1); +}); + +test('the margin sets and clears a bookmark with one click each', async ({ page }) => { + await clickMargin(page, 6); + await expect.poll(() => ribbonLines(page)).toEqual([6]); + await clickMargin(page, 6); + await expect.poll(() => ribbonLines(page)).toEqual([]); + await expect.poll(async () => (await stored(page, 'bookmarks')).length).toBe(0); +}); + +test('the list renames, recolours, and jumps from another file', async ({ page }) => { + await clickMargin(page, 3); + await page.getByRole('tab', { name: /Bookmarks/ }).click(); + await expect(page.locator('.ac-bookmark-label')).toHaveText(['export async function runJob(job) {']); + + await page.getByRole('button', { name: /^Rename export async/ }).click(); + await page.locator('.ac-bookmark-rename').fill('Runner entry point'); + await page.locator('.ac-bookmark-rename').press('Enter'); + await expect(page.locator('.ac-bookmark-label')).toHaveText(['Runner entry point']); + + await page.getByRole('button', { name: 'Bookmark colour' }).click(); + await page.locator('.ac-bookmark-swatches').getByRole('button', { name: 'Colour #d1495b' }).click(); + await expect + .poll(async () => (await stored(page, 'bookmarks'))[0]) + .toMatchObject({ name: 'Runner entry point', color: '#d1495b' }); + + await page.getByRole('tab', { name: 'Files' }).click(); + await openFile(page, 'src/util.py'); + await page.getByRole('tab', { name: /Bookmarks/ }).click(); + await page.locator('.ac-bookmark-name').click(); + await expect(page.locator('.ac-path')).toHaveText('src/runner.ts'); + await expect(page.locator('.cm-lineNumbers .cm-activeLineGutter')).toHaveText('3'); +}); + +test('a bookmark follows an edit, is recorded on save, and is there after a reload', async ({ page }) => { + await clickMargin(page, 3); + await insertAtTop(page, '// one\n// two\n'); + await expect.poll(() => ribbonLines(page)).toEqual([5]); + + await withCtrl(page, 's'); + await expect(page.getByRole('button', { name: 'Saved' })).toBeVisible(); + await expect.poll(async () => (await stored(page, 'bookmarks'))[0]?.line).toBe(4); + + await page.reload(); + await reopenRecent(page); + await openFile(page, 'src/runner.ts'); + await expect.poll(() => ribbonLines(page)).toEqual([5]); +}); diff --git a/e2e/export.spec.ts b/e2e/export.spec.ts new file mode 100644 index 0000000..d45bc00 --- /dev/null +++ b/e2e/export.spec.ts @@ -0,0 +1,22 @@ +import { readFile } from 'node:fs/promises'; +import { expect, test } from '@playwright/test'; +import { drawOnLine, openDemo, openFile } from './app'; + +test('Export annotations downloads one JSON file with the source, its layer and its ink', async ({ page }) => { + await openDemo(page); + await openFile(page, 'src/runner.ts'); + await page.getByRole('button', { name: 'Pen' }).click(); + await drawOnLine(page, 4); + + await page.getByRole('button', { name: 'Layers and more' }).click(); + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Export annotations' }).click(), + ]); + + expect(download.suggestedFilename()).toMatch(/^demo-repo-annotations-\d{4}-\d\d-\d\d\.json$/); + const dump = JSON.parse(await readFile((await download.path())!, 'utf8')); + expect(dump.source.name).toBe('demo/repo'); + expect(dump.layers).toHaveLength(1); + expect(dump.annotations).toHaveLength(1); +}); diff --git a/e2e/files.spec.ts b/e2e/files.spec.ts new file mode 100644 index 0000000..d1d6855 --- /dev/null +++ b/e2e/files.spec.ts @@ -0,0 +1,240 @@ +/** + * Creating, renaming and deleting files - as local files in a GitHub source, and on disk in + * a picked folder. The folder is simulated with the origin-private file system, which hands + * out the same FileSystemDirectoryHandle the picker does, and every change is checked + * against the files themselves. + */ + +import { chromium, expect, test, type Page } from '@playwright/test'; +import { fileId, openDemo, openFile, reopenRecent, stored, tapLine, withCtrl, writeNote } from './app'; + +interface StoredFile { + id: string; + path: string; + content?: string; + local?: boolean; +} +interface StoredAnnotation { + fileId: string; +} + +async function newFile(page: Page, path: string) { + await page.getByRole('button', { name: 'New file' }).click(); + const field = page.getByRole('textbox', { name: 'New file path' }); + await field.fill(path); + await field.press('Enter'); +} + +async function record(page: Page, id: string): Promise { + return (await stored(page, 'files')).find((f) => f.id === id); +} + +test.describe('in a GitHub source', () => { + test.beforeEach(async ({ page }) => { + await openDemo(page); + await openFile(page, 'src/runner.ts'); + }); + + test('upstream files cannot be renamed or deleted', async ({ page }) => { + await page.locator('.ac-row[title="src/runner.ts"]').hover(); + await expect(page.getByRole('button', { name: 'Delete src/runner.ts' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Rename src/runner.ts' })).toHaveCount(0); + }); + + test('a new file starts beside the open one, opens for editing, and keeps its text', async ({ page }) => { + await page.getByRole('button', { name: 'New file' }).click(); + await expect(page.getByRole('textbox', { name: 'New file path' })).toHaveValue('src/'); + await page.keyboard.type('notes/todo.md'); + await page.keyboard.press('Enter'); + + await expect(page.locator('.ac-path')).toHaveText('src/notes/todo.md'); + await expect(page.getByRole('button', { name: 'Edit', exact: true })).toHaveClass(/is-active/); + await expect(page.locator('.ac-row[title="src/notes/todo.md"] .ac-row-badge')).toHaveText('new'); + + await page.locator('.cm-content').click(); + await page.keyboard.type('# todo'); + await withCtrl(page, 's'); + await expect.poll(async () => (await record(page, fileId('src/notes/todo.md')))?.content).toBe('# todo'); + expect((await record(page, fileId('src/notes/todo.md')))?.local).toBe(true); + + await page.reload(); + await reopenRecent(page); + await openFile(page, 'src/notes/todo.md'); + await expect(page.locator('.cm-content')).toHaveText('# todo'); + }); + + test('renaming a created file carries its text and notes', async ({ page }) => { + await newFile(page, 'src/notes/todo.md'); + await page.locator('.cm-content').click(); + await page.keyboard.type('# todo\nread the runner'); + await withCtrl(page, 's'); + await page.getByRole('button', { name: 'Read', exact: true }).click(); + await page.getByRole('button', { name: 'Note', exact: true }).click(); + await writeNote(page, 2, 'done by friday'); + await page.keyboard.press('Escape'); + + await page.locator('.ac-row[title="src/notes/todo.md"]').hover(); + await page.getByRole('button', { name: 'Rename src/notes/todo.md' }).click(); + const field = page.getByRole('textbox', { name: 'Rename src/notes/todo.md' }); + expect(await field.evaluate((e: HTMLInputElement) => e.value.slice(e.selectionStart!, e.selectionEnd!))).toBe( + 'todo', + ); + await page.keyboard.type('done'); + await page.keyboard.press('Enter'); + + await expect(page.locator('.ac-path')).toHaveText('src/notes/done.md'); + await expect(page.locator('.ac-note-chip')).toHaveText('done by friday'); + await expect + .poll(async () => (await record(page, fileId('src/notes/done.md')))?.content) + .toBe('# todo\nread the runner'); + expect(await record(page, fileId('src/notes/todo.md'))).toBeUndefined(); + const notes = await stored(page, 'annotations'); + expect(notes.map((n) => n.fileId)).toEqual([fileId('src/notes/done.md')]); + }); + + test('deleting a created file says what goes with it, then removes it all', async ({ page }) => { + await newFile(page, 'scratch.md'); + await page.getByRole('button', { name: 'Read', exact: true }).click(); + await page.getByRole('button', { name: 'Note', exact: true }).click(); + await writeNote(page, 1, 'temporary'); + await page.keyboard.press('Escape'); + + await page.locator('.ac-row[title="scratch.md"]').hover(); + await page.getByRole('button', { name: 'Delete scratch.md' }).click(); + await expect(page.locator('.ac-tree-panel')).toContainText('Its 1 annotation goes with it.'); + await page.locator('.ac-tree-panel').getByRole('button', { name: 'Delete' }).click(); + + await expect(page.locator('.ac-row[title="scratch.md"]')).toHaveCount(0); + await expect(page.locator('.cm-content')).toHaveCount(0); + await expect.poll(async () => record(page, fileId('scratch.md'))).toBeUndefined(); + expect(await stored(page, 'annotations')).toHaveLength(0); + }); + + test('a name that is taken is refused, with a reason', async ({ page }) => { + await newFile(page, 'readme.MD'); + await expect(page.locator('.ac-tree-panel.is-error')).toContainText('already there'); + }); +}); + +async function seedFolder(page: Page): Promise { + await page.goto('/app/'); + await expect(page.getByRole('heading', { name: 'Open a codebase' })).toBeVisible(); + await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const proj = await root.getDirectoryHandle('proj', { create: true }); + const write = async (dir: FileSystemDirectoryHandle, name: string, text: string) => { + const w = await (await dir.getFileHandle(name, { create: true })).createWritable(); + await w.write(text); + await w.close(); + }; + await write(proj, 'a.ts', 'export const a = 1;\n'); + await write(await proj.getDirectoryHandle('src', { create: true }), 'b.ts', 'export const b = 2;\n'); + + const db = await new Promise((resolve) => { + const req = indexedDB.open('annotatecode'); + req.onsuccess = () => resolve(req.result); + }); + const tx = db.transaction(['sources', 'files', 'fsHandles'], 'readwrite'); + const now = Date.now(); + tx.objectStore('sources').put({ + id: 'local:proj', + kind: 'local-fs', + name: 'proj', + fsHandleKey: 'local:proj', + createdAt: now, + lastOpenedAt: now, + }); + tx.objectStore('files').put({ + id: 'local:proj:a.ts', + sourceId: 'local:proj', + path: 'a.ts', + size: 1, + edited: false, + contentHash: '', + updatedAt: now, + }); + tx.objectStore('fsHandles').put({ key: 'local:proj', handle: proj }); + await new Promise((resolve) => (tx.oncomplete = resolve)); + db.close(); + }); + await page.reload(); + await reopenRecent(page); +} + +/** + * A picked folder needs a persistent browser profile. Playwright's default context is + * off the record, and there, storing a folder handle in IndexedDB closes the page - which + * no reader's browser does. + */ +const folderTest = test.extend<{ folderPage: Page }>({ + folderPage: async ({}, use, testInfo) => { + const context = await chromium.launchPersistentContext(testInfo.outputPath('profile'), { + baseURL: testInfo.project.use.baseURL, + viewport: { width: 1400, height: 900 }, + }); + const page = context.pages()[0] ?? (await context.newPage()); + await seedFolder(page); + await use(page); + await context.close(); + }, +}); + +folderTest.describe('in a picked folder', () => { + /** Read a file from the simulated folder, or null if it is not there. */ + const disk = (page: Page, path: string) => + page.evaluate(async (p) => { + let dir = await (await navigator.storage.getDirectory()).getDirectoryHandle('proj'); + const parts = p.split('/'); + const name = parts.pop()!; + try { + for (const part of parts) dir = await dir.getDirectoryHandle(part); + return await (await (await dir.getFileHandle(name)).getFile()).text(); + } catch { + return null; + } + }, path); + + + folderTest('create, write, rename and delete all happen on disk', async ({ folderPage: page }) => { + await page.locator('.ac-row[title="a.ts"]').hover(); + await expect(page.getByRole('button', { name: 'Delete a.ts' })).toBeVisible(); + + await newFile(page, 'src/c.ts'); + await expect(page.locator('.ac-path')).toHaveText('src/c.ts'); + await expect.poll(() => disk(page, 'src/c.ts')).toBe(''); + await page.locator('.cm-content').click(); + await page.keyboard.type('export const c = 3;'); + await withCtrl(page, 's'); + await expect.poll(() => disk(page, 'src/c.ts')).toBe('export const c = 3;'); + + await page.locator('.ac-row[title="a.ts"]').hover(); + await page.getByRole('button', { name: 'Rename a.ts' }).click(); + await page.getByRole('textbox', { name: 'Rename a.ts' }).fill('lib/a2.ts'); + await page.keyboard.press('Enter'); + await expect.poll(() => disk(page, 'lib/a2.ts')).toBe('export const a = 1;\n'); + expect(await disk(page, 'a.ts')).toBeNull(); + + await page.locator('.ac-row[title="src/b.ts"]').hover(); + await page.getByRole('button', { name: 'Delete src/b.ts' }).click(); + await expect(page.locator('.ac-tree-panel')).toContainText('from disk'); + await page.locator('.ac-tree-panel').getByRole('button', { name: 'Delete' }).click(); + await expect.poll(() => disk(page, 'src/b.ts')).toBeNull(); + + // Each file appears exactly once: the adapter's own list is not the app's. + const rows = await page.locator('.ac-row.file').evaluateAll((els) => els.map((e) => e.getAttribute('title'))); + expect(new Set(rows).size).toBe(rows.length); + }); + + folderTest('tapping a line in a created file with the Note tool still writes a note', async ({ folderPage: page }) => { + await newFile(page, 'notes.md'); + await page.locator('.cm-content').click(); + await page.keyboard.type('one\ntwo'); + await withCtrl(page, 's'); + await page.getByRole('button', { name: 'Read', exact: true }).click(); + await page.getByRole('button', { name: 'Note', exact: true }).click(); + await tapLine(page, 2); + await page.keyboard.type('on disk too'); + await withCtrl(page, 'Enter'); + await expect(page.locator('.ac-note-chip')).toHaveText('on disk too'); + }); +}); diff --git a/e2e/ink-scroll.spec.ts b/e2e/ink-scroll.spec.ts new file mode 100644 index 0000000..3226fc9 --- /dev/null +++ b/e2e/ink-scroll.spec.ts @@ -0,0 +1,94 @@ +/** + * The plan's highest risk: ink that drifts from the code it was written on. + * + * A stroke is drawn once and then measured against its line at several scroll offsets and + * after the layout changes width. If the ink and the text ever disagree about where the line + * is, the offset between them changes - which is exactly the desync the ink engine is built + * around avoiding. + */ + +import { expect, test, type Page } from '@playwright/test'; +import { drawOnLine, lineBox, openDemo, openFile, reopenRecent, scrollToLine } from './app'; + +/** The centre of everything painted on the pen canvas, relative to a line and the code's left edge. */ +async function inkRelativeTo(page: Page, line: number): Promise<{ dx: number; dy: number }> { + const box = await lineBox(page, line); + const centre = await page.evaluate(() => { + const canvas = document.querySelector( + '.ac-ink:not(.ac-ink-highlighter):not(.ac-ink-wet)', + )!; + const r = canvas.getBoundingClientRect(); + const scale = canvas.width / r.width; + const { data, width, height } = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height); + let sum = 0; + let sx = 0; + let sy = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const a = data[(y * width + x) * 4 + 3]!; + if (a === 0) continue; + sum += a; + sx += x * a; + sy += y * a; + } + } + if (sum === 0) return null; + return { x: r.left + sx / sum / scale, y: r.top + sy / sum / scale }; + }); + if (!centre) throw new Error('no ink on the canvas'); + return { dx: centre.x - box.left, dy: centre.y - box.top }; +} + +test('a stroke stays welded to its line while the document scrolls', async ({ page }) => { + await openDemo(page); + await openFile(page, 'src/runner.ts'); + await page.getByRole('button', { name: 'Pen' }).click(); + + await scrollToLine(page, 40); + await drawOnLine(page, 50); + const drawn = await inkRelativeTo(page, 50); + + for (const target of [44, 47, 32, 49, 38]) { + await scrollToLine(page, target); + const now = await inkRelativeTo(page, 50); + expect(Math.abs(now.dy - drawn.dy), `vertical drift after scrolling to ${target}`).toBeLessThan(1); + expect(Math.abs(now.dx - drawn.dx), `horizontal drift after scrolling to ${target}`).toBeLessThan(1); + } +}); + +test('a stroke follows the code when the sidebar closes and the code moves left', async ({ page }) => { + await openDemo(page); + await openFile(page, 'src/runner.ts'); + await page.getByRole('button', { name: 'Pen' }).click(); + await drawOnLine(page, 6); + const before = await inkRelativeTo(page, 6); + const leftBefore = (await lineBox(page, 6)).left; + + await page.getByRole('button', { name: 'Hide sidebar' }).click(); + await expect(page.locator('.ac-sidebar')).toHaveClass(/is-collapsed/); + await page.waitForTimeout(400); + + const leftAfter = (await lineBox(page, 6)).left; + expect(leftAfter).toBeLessThan(leftBefore - 100); + + const after = await inkRelativeTo(page, 6); + expect(Math.abs(after.dx - before.dx)).toBeLessThan(1); + expect(Math.abs(after.dy - before.dy)).toBeLessThan(1); +}); + +test('a stroke comes back exactly where it was after a reload', async ({ page }) => { + await openDemo(page); + await openFile(page, 'src/runner.ts'); + await page.getByRole('button', { name: 'Pen' }).click(); + await drawOnLine(page, 5); + const before = await inkRelativeTo(page, 5); + + await page.reload(); + await reopenRecent(page); + await openFile(page, 'src/runner.ts'); + await page.waitForTimeout(300); + + const after = await inkRelativeTo(page, 5); + expect(Math.abs(after.dx - before.dx)).toBeLessThan(1); + expect(Math.abs(after.dy - before.dy)).toBeLessThan(1); +}); diff --git a/e2e/layers.spec.ts b/e2e/layers.spec.ts new file mode 100644 index 0000000..f3af551 --- /dev/null +++ b/e2e/layers.spec.ts @@ -0,0 +1,119 @@ +import { expect, test, type Page } from '@playwright/test'; +import { drawOnLine, inkOn, openDemo, openFile, reopenRecent, stored } from './app'; + +interface StoredLayer { + id: string; + name: string; + visible: boolean; +} +interface StoredAnnotation { + id: string; + layerId: string; +} + +const menu = (page: Page) => page.getByRole('button', { name: 'Layers and more' }); + +async function newLayer(page: Page, name: string) { + await page.locator('.ac-layer-new').click(); + const field = page.locator('.ac-layer-rename'); + await expect(field).toBeFocused(); + await field.fill(name); + await field.press('Enter'); +} + +test.beforeEach(async ({ page }) => { + await openDemo(page); + await openFile(page, 'src/runner.ts'); + await page.getByRole('button', { name: 'Pen' }).click(); +}); + +test('new ink goes into the active layer, and a new layer becomes active', async ({ page }) => { + await drawOnLine(page, 4); + await menu(page).click(); + await expect(page.locator('.ac-layer')).toHaveCount(1); + await expect(page.locator('.ac-layer-count')).toHaveText('1'); + + await newLayer(page, 'Questions'); + await expect(page.locator('.ac-layer.is-active .ac-layer-name')).toHaveText('Questions'); + + await page.keyboard.press('Escape'); + await expect(page.locator('.ac-overflow')).toHaveCount(0); + await expect(page.locator('.ac-tool.is-active')).toHaveCount(1); + + await drawOnLine(page, 6); + const layers = await stored(page, 'layers'); + const questions = layers.find((l) => l.name === 'Questions')!; + await expect + .poll(async () => + (await stored(page, 'annotations')).filter((a) => a.layerId === questions.id).length, + ) + .toBe(1); +}); + +test('hiding a layer hides only its ink, and the eraser cannot touch it', async ({ page }) => { + await drawOnLine(page, 4); + await menu(page).click(); + await newLayer(page, 'Questions'); + await page.keyboard.press('Escape'); + await drawOnLine(page, 6); + + await menu(page).click(); + await page.getByRole('button', { name: 'Hide Notes' }).click(); + await page.keyboard.press('Escape'); + await expect.poll(() => inkOn(page, 4)).toBe(0); + expect(await inkOn(page, 6)).toBeGreaterThan(0); + + await page.getByRole('button', { name: 'Eraser' }).click(); + await drawOnLine(page, 4, 220); + // Give a mistaken erase every chance to land before checking that none did. + await page.waitForTimeout(300); + expect(await stored(page, 'annotations')).toHaveLength(2); + + await menu(page).click(); + await page.getByRole('button', { name: 'Show Notes' }).click(); + await expect.poll(() => inkOn(page, 4)).toBeGreaterThan(0); +}); + +test('deleting a layer can keep its annotations by merging them into another', async ({ page }) => { + await drawOnLine(page, 4); + await menu(page).click(); + await newLayer(page, 'Questions'); + await page.keyboard.press('Escape'); + await drawOnLine(page, 6); + + await menu(page).click(); + await page.getByRole('button', { name: 'Delete Questions' }).click(); + await page.getByRole('button', { name: 'Move and delete layer' }).click(); + + await expect(page.locator('.ac-layer')).toHaveCount(1); + const [notes] = await stored(page, 'layers'); + await expect + .poll(async () => { + const ink = await stored(page, 'annotations'); + return ink.length === 2 && ink.every((a) => a.layerId === notes!.id); + }) + .toBe(true); + expect(await inkOn(page, 6)).toBeGreaterThan(0); +}); + +test('the last layer cannot be deleted', async ({ page }) => { + await menu(page).click(); + await expect(page.getByRole('button', { name: 'Delete Notes' })).toBeDisabled(); +}); + +test('visibility and the active layer survive a reload', async ({ page }) => { + await drawOnLine(page, 4); + await menu(page).click(); + await newLayer(page, 'Later'); + await page.getByRole('button', { name: 'Hide Notes' }).click(); + + await page.reload(); + await reopenRecent(page); + await openFile(page, 'src/runner.ts'); + await page.waitForTimeout(300); + expect(await inkOn(page, 4)).toBe(0); + + await menu(page).click(); + await expect(page.locator('.ac-layer.is-active .ac-layer-name')).toHaveText('Later'); + await expect(page.locator('.ac-layer.is-hidden .ac-layer-name')).toHaveText('Notes'); +}); diff --git a/e2e/notes.spec.ts b/e2e/notes.spec.ts new file mode 100644 index 0000000..aa0b5bd --- /dev/null +++ b/e2e/notes.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; +import { + chipLines, + drawOnLine, + insertAtTop, + openDemo, + openFile, + reopenRecent, + stored, + tapLine, + withCtrl, + writeNote, +} from './app'; + +interface StoredAnnotation { + id: string; + kind: string; + text?: string; + anchor: { line: number }; +} + +test.beforeEach(async ({ page }) => { + await openDemo(page); + await openFile(page, 'src/runner.ts'); +}); + +test('the Note tool writes a note at the end of its line without moving any line', async ({ page }) => { + const heights = () => + page.$$eval('.cm-line', (lines) => lines.slice(0, 12).map((l) => l.getBoundingClientRect().height)); + const before = await heights(); + + await page.getByRole('button', { name: 'Note', exact: true }).click(); + await writeNote(page, 4, 'this is where the job actually runs'); + + expect(await chipLines(page)).toEqual([{ line: 4, text: 'this is where the job actually runs' }]); + expect(await heights()).toEqual(before); + await expect + .poll(async () => (await stored(page, 'annotations'))[0]) + .toMatchObject({ kind: 'note', text: 'this is where the job actually runs' }); +}); + +test('a chip opens its note for editing, and an emptied note is deleted', async ({ page }) => { + await page.getByRole('button', { name: 'Note', exact: true }).click(); + await writeNote(page, 4, 'first draft'); + await page.keyboard.press('Escape'); + + await page.locator('.ac-note-chip').click(); + await expect(page.locator('.ac-note-field')).toHaveValue('first draft'); + await page.keyboard.type(', revised'); + await page.getByRole('button', { name: 'Done' }).click(); + await expect + .poll(async () => (await stored(page, 'annotations'))[0]?.text) + .toBe('first draft, revised'); + + await page.locator('.ac-note-chip').click(); + await page.locator('.ac-note-field').fill(''); + await page.getByRole('button', { name: 'Done' }).click(); + await expect(page.locator('.ac-note-chip')).toHaveCount(0); + await expect.poll(async () => (await stored(page, 'annotations')).length).toBe(0); +}); + +test('tapping another line keeps the note in hand; Escape discards a new one', async ({ page }) => { + await page.getByRole('button', { name: 'Note', exact: true }).click(); + await tapLine(page, 6); + await page.keyboard.type('kept'); + await tapLine(page, 20); + await expect(page.locator('.ac-note-card')).toContainText('New note · line 20'); + await page.keyboard.press('Escape'); + + await expect + .poll(async () => (await stored(page, 'annotations')).map((n) => n.text)) + .toEqual(['kept']); + await expect(page.locator('.ac-tool.is-active')).toHaveCount(1); +}); + +test('Ctrl+Z inside a note is the note’s, not the ink’s', async ({ page }) => { + await page.getByRole('button', { name: 'Pen' }).click(); + await drawOnLine(page, 12); + await page.getByRole('button', { name: 'Note', exact: true }).click(); + await writeNote(page, 4, 'note'); + await page.keyboard.press('Escape'); + + await page.locator('.ac-note-chip').click(); + await page.keyboard.type('xyz'); + await withCtrl(page, 'z'); + await page.waitForTimeout(300); + + const all = await stored(page, 'annotations'); + expect(all.filter((a) => a.kind === 'ink')).toHaveLength(1); +}); + +test('a note follows an edit above it, is re-anchored on save, and is found after a reload', async ({ page }) => { + await page.getByRole('button', { name: 'Note', exact: true }).click(); + await writeNote(page, 4, 'moves with the code'); + await page.keyboard.press('Escape'); + + await insertAtTop(page, '// one\n// two\n'); + await expect.poll(() => chipLines(page)).toEqual([{ line: 6, text: 'moves with the code' }]); + await withCtrl(page, 's'); + await expect(page.getByRole('button', { name: 'Saved' })).toBeVisible(); + await expect + .poll(async () => (await stored(page, 'annotations'))[0]?.anchor.line) + .toBe(5); + + await page.reload(); + await reopenRecent(page); + await openFile(page, 'src/runner.ts'); + await expect.poll(() => chipLines(page)).toEqual([{ line: 6, text: 'moves with the code' }]); +}); diff --git a/e2e/search.spec.ts b/e2e/search.spec.ts new file mode 100644 index 0000000..36619d6 --- /dev/null +++ b/e2e/search.spec.ts @@ -0,0 +1,85 @@ +/** + * The plan's test for Phase 4: you can find a note you wrote last week by typing three + * words. Four notes across two files and two layers, one of them hidden, and a bookmark. + */ + +import { expect, test, type Page } from '@playwright/test'; +import { lineBox, openDemo, openFile, scrollToLine, writeNote } from './app'; + +const results = (page: Page) => page.locator('.ac-result-text'); + +async function search(page: Page, query: string) { + const field = page.getByRole('searchbox', { name: 'Search notes and bookmarks' }); + await field.fill(query); +} + +test.beforeEach(async ({ page }) => { + await openDemo(page); + await openFile(page, 'src/runner.ts'); + + await page.getByRole('button', { name: 'Note', exact: true }).click(); + await writeNote(page, 4, 'this is where the job actually runs'); + await scrollToLine(page, 92); + await writeNote(page, 92, 'boot the WebContainer once per tab'); + await page.keyboard.press('Escape'); + + const box = await lineBox(page, 91); + const margin = await page.locator('.ac-bookmark-gutter').boundingBox(); + await page.mouse.click(margin!.x + margin!.width / 2, box.top + box.height / 2); + + await openFile(page, 'src/util.py'); + await page.getByRole('button', { name: 'Note', exact: true }).click(); + await writeNote(page, 3, 'multiplier helpers are pure'); + + await page.getByRole('button', { name: 'Layers and more' }).click(); + await page.locator('.ac-layer-new').click(); + await page.locator('.ac-layer-rename').fill('Exam'); + await page.locator('.ac-layer-rename').press('Enter'); + await page.keyboard.press('Escape'); + await writeNote(page, 9, 'the retry gives up after five attempts and logs it'); + await page.getByRole('button', { name: 'Layers and more' }).click(); + await page.getByRole('button', { name: 'Hide Exam' }).click(); + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); +}); + +test('Ctrl+Shift+F opens the index, which lists everything', async ({ page }) => { + await page.keyboard.press('Control+Shift+F'); + await expect(page.getByRole('searchbox', { name: 'Search notes and bookmarks' })).toBeFocused(); + await expect(page.locator('.ac-search-summary')).toHaveText('4 notes · 1 bookmark'); +}); + +test('every word must match, the path counts, and hidden layers are still searched', async ({ page }) => { + await page.keyboard.press('Control+Shift+F'); + + await search(page, 'webcontainer'); + await expect(results(page)).toHaveText([ + 'boot the WebContainer once per tab', + 'export function setupWebContainer(options) {', + ]); + await expect(page.locator('.ac-result-text mark').first()).toHaveText('WebContainer'); + + await search(page, 'runner job runs'); + await expect(results(page)).toHaveText(['this is where the job actually runs']); + + await search(page, 'retry five logs'); + await expect(results(page)).toHaveText(['the retry gives up after five attempts and logs it']); + await expect(page.locator('.ac-result-meta')).toContainText('hidden layer'); + + await search(page, 'zebra'); + await expect(page.locator('.ac-search-summary')).toHaveText('Nothing matches “zebra”'); +}); + +test('a result opens its file on its line; Enter takes the first', async ({ page }) => { + await page.keyboard.press('Control+Shift+F'); + await search(page, 'boot container'); + await results(page).first().click(); + await expect(page.locator('.ac-path')).toHaveText('src/runner.ts'); + await expect(page.locator('.cm-lineNumbers .cm-activeLineGutter')).toHaveText('92'); + + await page.keyboard.press('Control+Shift+F'); + await search(page, 'multiplier'); + await page.keyboard.press('Enter'); + await expect(page.locator('.ac-path')).toHaveText('src/util.py'); + await expect(page.locator('.cm-lineNumbers .cm-activeLineGutter')).toHaveText('3'); +}); diff --git a/package-lock.json b/package-lock.json index 0fd7b92..a3da57a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@types/node": "^26.5.1", "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", @@ -1412,6 +1413,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2478,6 +2495,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", diff --git a/package.json b/package.json index 9b8f958..893c6fc 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "playwright test", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -29,6 +30,7 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@types/node": "^26.5.1", "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..2faa0a7 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * End-to-end tests, in a real browser against the dev server. + * + * Vitest covers everything that can be checked without a browser. These cover what cannot: + * ink that stays on its line while the document scrolls (the plan's highest risk), and the + * flows that cross the editor, the database and the UI - drawing, layers, notes, bookmarks, + * search, files. Each test runs in a fresh browser context, so each starts with an empty + * IndexedDB and seeds exactly what it needs. + */ +const PORT = 5174; + +export default defineConfig({ + testDir: 'e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['github'], ['list']] : 'list', + use: { + baseURL: `http://localhost:${PORT}`, + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], viewport: { width: 1400, height: 900 } }, + }, + ], + webServer: { + command: `npx vite --port ${PORT} --strictPort`, + url: `http://localhost:${PORT}/app/`, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/src/app/App.tsx b/src/app/App.tsx index 61c2bfa..dae3c6f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -10,22 +10,31 @@ * pen, the keyboard should not be typing into the document underneath it. */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { EditorView, ViewUpdate } from '@codemirror/view'; import { isInkTool, usePalette } from '@/store/palette'; import { lineMapper } from '@/ink/reflow'; +import { BOOKMARK_COLOR } from '@/organise/bookmarks'; +import { noteMarks } from '@/organise/notes'; +import { useBookmarks } from '@/store/bookmarks'; +import { useNoteEditor } from '@/store/noteEditor'; import { useInk } from '@/store/ink'; +import { useLayers } from '@/store/layers'; import { useSession } from '@/store/session'; -import { FileTree } from '@/ui/FileTree/FileTree'; +import { useSidebar } from '@/store/sidebar'; import { DisplacedTray } from '@/ui/Ink/DisplacedTray'; import { InkSurface } from '@/ui/Ink/InkSurface'; +import { NoteComposer } from '@/ui/Notes/NoteComposer'; import { OpenScreen } from '@/ui/Open/OpenScreen'; +import { bookmarkLineIn } from '@/ui/Reader/bookmarkGutter'; import { CodeView } from '@/ui/Reader/CodeView'; import { languageName } from '@/ui/Reader/language'; +import { Sidebar } from '@/ui/Sidebar/Sidebar'; import { ToolPalette } from '@/ui/ToolPalette/ToolPalette'; export function App() { - const [sidebarOpen, setSidebarOpen] = useState(true); + const sidebarOpen = useSidebar((s) => s.open); + const toggleSidebar = useSidebar((s) => s.toggle); const [view, setView] = useState(null); const status = useSession((s) => s.status); @@ -35,6 +44,7 @@ export function App() { const fileError = useSession((s) => s.fileError); const loadingFile = useSession((s) => s.loadingFile); const pendingLine = useSession((s) => s.pendingLine); + const pendingIsJump = useSession((s) => s.pendingIsJump); const mode = useSession((s) => s.mode); const dirty = useSession((s) => s.dirty); const saving = useSession((s) => s.saving); @@ -56,6 +66,18 @@ export function App() { const canUndo = useInk((s) => s.stacks.undo.length > 0); const canRedo = useInk((s) => s.stacks.redo.length > 0); + // Layers and bookmarks belong to the source, not the file: load them once per codebase. + const sourceId = source?.id ?? null; + useEffect(() => { + if (sourceId) { + void useLayers.getState().load(sourceId); + void useBookmarks.getState().load(sourceId); + } else { + useLayers.getState().unload(); + useBookmarks.getState().unload(); + } + }, [sourceId]); + // Load this file's annotations, and resolve them against the text as it stands now. // // Once per file, not once per keystroke: `content` changes on every edit, and reloading @@ -66,14 +88,58 @@ export function App() { useEffect(() => { if (!fileId || !source) { closeInk(); + useBookmarks.getState().closeFile(); loadedFor.current = null; return; } if (content === null || loadedFor.current === fileId) return; loadedFor.current = fileId; - void openInk(fileId, source.id, content); + void openInk(fileId, content); + useBookmarks.getState().openFile(fileId, content); }, [fileId, content, source, openInk, closeInk]); + // The open file's bookmarks, as ribbons for the margin. Empty until they have been placed + // in *this* file, so a ribbon from the previous file never flashes on the new one. + const bookmarkFile = useBookmarks((s) => s.fileId); + const bookmarkAll = useBookmarks((s) => s.all); + const bookmarkLines = useBookmarks((s) => s.lines); + const ribbons = useMemo( + () => + bookmarkFile !== fileId + ? [] + : bookmarkAll.flatMap((b) => { + const line = bookmarkLines.get(b.id); + return line === undefined + ? [] + : [{ line, color: b.color ?? BOOKMARK_COLOR, name: b.name ?? '' }]; + }), + [bookmarkFile, fileId, bookmarkAll, bookmarkLines], + ); + + // Typed notes on visible layers, as chips at the end of their lines. + const placedInk = useInk((s) => s.placed); + const inkFile = useInk((s) => s.fileId); + const hiddenLayers = useLayers((s) => s.hidden); + const notes = useMemo( + () => (inkFile !== fileId ? [] : noteMarks(placedInk, hiddenLayers)), + [inkFile, fileId, placedInk, hiddenLayers], + ); + const openNote = useCallback((id: string) => void useNoteEditor.getState().edit(id), []); + + // Leaving a file keeps the note being written on it. The cleanup runs before the ink + // working set moves to the next file, while the note still has somewhere to be saved. + useEffect( + () => () => { + void useNoteEditor.getState().commit(); + }, + [fileId], + ); + + const toggleBookmark = useCallback((line: number) => { + const text = useSession.getState().content; + if (text !== null) void useBookmarks.getState().toggle(line, text); + }, []); + /** * Carry the ink across an edit. * @@ -85,22 +151,36 @@ export function App() { (text: string, update: ViewUpdate) => { edit(text); - useInk - .getState() - .shift(lineMapper(update.startState.doc, update.state.doc, update.changes)); + const move = lineMapper(update.startState.doc, update.state.doc, update.changes); + useInk.getState().shift(move); + useBookmarks.getState().shift(move); }, [edit], ); useEffect(() => { const onKey = (e: KeyboardEvent) => { - // Escape disarms the current tool — the fastest way back to plain reading. + // Escape closes an open palette menu first, then disarms the tool — the fastest way + // back to plain reading. + if (e.key === 'Escape' && usePalette.getState().overflowOpen) { + usePalette.getState().closeOverflow(); + return; + } if (e.key === 'Escape' && usePalette.getState().activeTool) { clearTool(); return; } const accel = e.metaKey || e.ctrlKey; + // A text field has its own undo; the ink's must not reach into it. + const inField = (e.target as HTMLElement | null)?.closest?.('input, textarea, select'); + + // Search what you have written. Mod-F stays CodeMirror's search within the file. + if (accel && e.shiftKey && e.key.toLowerCase() === 'f') { + e.preventDefault(); + useSidebar.getState().focusSearch(); + return; + } if (accel && e.key.toLowerCase() === 's') { e.preventDefault(); void useSession.getState().save(); @@ -109,7 +189,7 @@ export function App() { // Undo/redo belong to the ink whenever there is ink to undo. In edit mode with a clean // stack it falls through to CodeMirror's own history, which owns the text. - if (accel && e.key.toLowerCase() === 'z') { + if (accel && e.key.toLowerCase() === 'z' && !inField) { const ink = useInk.getState(); const hasInkHistory = e.shiftKey ? ink.stacks.redo.length > 0 : ink.stacks.undo.length > 0; if (!hasInkHistory) return; @@ -144,9 +224,9 @@ export function App() { + )} + {activeFile && (
+ )} + + + +
+ + {coloring === bookmark.id && ( +
+ {SWATCHES.map((c) => ( +
+ )} + + ); + })} + + + ))} + + ); +} diff --git a/src/ui/Bookmarks/bookmarks.css b/src/ui/Bookmarks/bookmarks.css new file mode 100644 index 0000000..51c365d --- /dev/null +++ b/src/ui/Bookmarks/bookmarks.css @@ -0,0 +1,125 @@ +/* The bookmark list, in the sidebar. Flat and quiet, like the file tree beside it. */ + +.ac-bookmarks { + padding: 4px 0 16px; +} + +.ac-bookmark-group ul { + margin: 0; + padding: 0; + list-style: none; +} + +.ac-bookmark { + display: flex; + align-items: center; + gap: 2px; + min-height: 40px; + padding: 0 6px 0 12px; +} + +.ac-bookmark:hover { + background: #f1f1f1; +} + +/* A ribbon, the same shape as the one in the margin, so the list and the code agree. */ +.ac-bookmark-color { + width: 16px; + height: 22px; + flex: none; + padding: 0; + border: 0; + background: none; + cursor: pointer; + position: relative; +} + +.ac-bookmark-color::before, +.ac-bookmark-swatch::before { + content: ''; + position: absolute; + left: 3px; + top: 4px; + width: 10px; + height: 14px; + background: var(--ribbon); + clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 74%, 0 100%); +} + +.ac-bookmark-name { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + padding: 4px 6px; + border: 0; + background: none; + font: inherit; + text-align: left; + cursor: pointer; +} + +.ac-bookmark-label { + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 13px; + color: #262626; +} + +.ac-bookmark-line { + font-size: 11.5px; + color: #9a9a9a; + font-variant-numeric: tabular-nums; +} + +.ac-bookmark.is-displaced .ac-bookmark-line { + color: #b7791f; +} + +.ac-bookmark-rename { + flex: 1; + min-width: 0; + height: 28px; + margin: 0 4px; + padding: 0 7px; + border: 1px solid #c7c7cc; + border-radius: 6px; + font: inherit; + font-size: 13px; + outline: none; +} + +.ac-bookmark-rename:focus { + border-color: #171717; +} + +.ac-bookmark-swatches { + display: flex; + gap: 2px; + padding: 0 12px 8px 30px; +} + +.ac-bookmark-swatch { + width: 22px; + height: 24px; + padding: 0; + border: 0; + border-radius: 5px; + background: none; + cursor: pointer; + position: relative; +} + +.ac-bookmark-swatch.is-selected, +.ac-bookmark-swatch:hover { + background: #ececec; +} + +.ac-bookmark-swatch::before { + left: 6px; + top: 5px; +} diff --git a/src/ui/FileTree/FileTree.tsx b/src/ui/FileTree/FileTree.tsx index 66bfd97..a2e56fc 100644 --- a/src/ui/FileTree/FileTree.tsx +++ b/src/ui/FileTree/FileTree.tsx @@ -4,10 +4,19 @@ * Rows are a fixed height and rendered through a window, so a 5,000-file repository puts * about thirty elements in the DOM rather than five thousand. The tree structure itself is * computed once in `tree.ts`; this component only ever looks at the flattened row list. + * + * It is also where files are created, renamed and deleted. Every one of those happens in + * place - a field where the row was, a confirmation under the list - because a dialog would + * cover the code, and the code is the thing being read. */ import { useEffect, useMemo, useRef, useState } from 'react'; +import type { FileEntry } from '@/model/types'; +import { listAnnotations } from '@/db'; +import { folderOf } from '@/sources/paths'; +import { useBookmarks } from '@/store/bookmarks'; import { useSession } from '@/store/session'; +import { useSidebar } from '@/store/sidebar'; import { flatten } from './tree'; import './filetree.css'; @@ -15,19 +24,143 @@ const ROW_HEIGHT = 24; /** Rendered above and below the viewport, so fast scrolling never shows a gap. */ const OVERSCAN = 8; +/** A path field that commits on Enter and gives up on Escape. */ +function PathField({ + initial, + label, + select, + onSubmit, + onCancel, +}: { + initial: string; + label: string; + /** Which part of the initial text to select: the base name, or nothing (caret at the end). */ + select: 'name' | 'end'; + onSubmit: (value: string) => Promise; + onCancel: () => void; +}) { + const [value, setValue] = useState(initial); + const field = useRef(null); + const busy = useRef(false); + + useEffect(() => { + const el = field.current; + if (!el) return; + el.focus(); + if (select === 'name') { + // Rename selects the file's name without its folder or extension, as every file + // manager does: the part most often changed. + const start = initial.lastIndexOf('/') + 1; + const dot = initial.lastIndexOf('.'); + el.setSelectionRange(start, dot > start ? dot : initial.length); + } else { + el.setSelectionRange(initial.length, initial.length); + } + }, [initial, select]); + + const submit = async () => { + if (busy.current) return; + busy.current = true; + const done = await onSubmit(value); + busy.current = false; + if (!done) field.current?.focus(); + }; + + return ( + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void submit(); + if (e.key === 'Escape') { + e.stopPropagation(); + onCancel(); + } + }} + /> + ); +} + +/** What deleting a file would take with it, said plainly before it happens. */ +function DeleteConfirm({ file, onDone }: { file: FileEntry; onDone: () => void }) { + const removeFile = useSession((s) => s.removeFile); + const bookmarks = useBookmarks((s) => s.all.filter((b) => b.fileId === file.id).length); + const [annotations, setAnnotations] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + void listAnnotations(file.id).then((list) => { + if (!cancelled) setAnnotations(list.length); + }); + return () => { + cancelled = true; + }; + }, [file.id]); + + const extras = [ + annotations ? `${annotations} annotation${annotations === 1 ? '' : 's'}` : '', + bookmarks ? `${bookmarks} bookmark${bookmarks === 1 ? '' : 's'}` : '', + ].filter(Boolean); + + return ( +
+

+ Delete {file.path} + {file.local ? '' : ' from disk'}? + {extras.length > 0 && + ` Its ${extras.join(' and ')} ${extras.length === 1 && extras[0]!.startsWith('1 ') ? 'goes' : 'go'} with it.`} +

+
+ + +
+
+ ); +} + export function FileTree() { const tree = useSession((s) => s.tree); + const files = useSession((s) => s.files); const expanded = useSession((s) => s.expanded); const activeFile = useSession((s) => s.activeFile); const warnings = useSession((s) => s.warnings); + const treeError = useSession((s) => s.treeError); const toggleDirectory = useSession((s) => s.toggleDirectory); const openFile = useSession((s) => s.openFile); + const createFile = useSession((s) => s.createFile); + const renameFile = useSession((s) => s.renameFile); + const canChangeFile = useSession((s) => s.canChangeFile); + const dismissTreeError = useSession((s) => s.dismissTreeError); const scroller = useRef(null); const [scrollTop, setScrollTop] = useState(0); const [height, setHeight] = useState(600); + const [creating, setCreating] = useState(false); + const [renaming, setRenaming] = useState(null); + const [deleting, setDeleting] = useState(null); + const rows = useMemo(() => flatten(tree, expanded), [tree, expanded]); + const byId = useMemo(() => new Map(files.map((f) => [f.id, f])), [files]); useEffect(() => { const element = scroller.current; @@ -57,8 +190,44 @@ export function FileTree() { const last = Math.min(rows.length, Math.ceil((scrollTop + height) / ROW_HEIGHT) + OVERSCAN); const visible = rows.slice(first, last); + const deletingFile = deleting ? byId.get(deleting) : undefined; + + /** One thing at a time: starting any of these closes the others. */ + const begin = (what: 'create' | 'rename' | 'delete', fileId?: string) => { + dismissTreeError(); + setCreating(what === 'create'); + setRenaming(what === 'rename' ? fileId! : null); + setDeleting(what === 'delete' ? fileId! : null); + }; + return (
+
+ {creating ? ( + { + const done = await createFile(value); + if (done) setCreating(false); + return done; + }} + onCancel={() => { + setCreating(false); + dismissTreeError(); + }} + /> + ) : ( + + )} +
+
{ const isActive = node.fileId !== undefined && node.fileId === activeFile?.id; const isOpen = node.kind === 'dir' && expanded.has(node.path); + const file = node.fileId ? byId.get(node.fileId) : undefined; + const changeable = file ? canChangeFile(file) : false; + const indent = 8 + depth * 13; + + if (file && renaming === file.id) { + return ( +
+ { + const done = await renameFile(file.id, value); + if (done) setRenaming(null); + return done; + }} + onCancel={() => { + setRenaming(null); + dismissTreeError(); + }} + /> +
+ ); + } return ( - + + {file && changeable && ( + + + + )} - {node.name} - +
); })}
+ {deletingFile && setDeleting(null)} />} + + {treeError && ( +
+

+ {treeError.message} + {treeError.hint && {treeError.hint}} +

+ +
+ )} + {warnings.length > 0 && (
{warnings.map((warning) => ( diff --git a/src/ui/FileTree/filetree.css b/src/ui/FileTree/filetree.css index 19494fe..f42abcc 100644 --- a/src/ui/FileTree/filetree.css +++ b/src/ui/FileTree/filetree.css @@ -101,3 +101,183 @@ .ac-tree-notes p:last-child { margin-bottom: 0; } + +/* ---- creating, renaming, deleting --------------------------------------- */ + +.ac-tree-bar { + flex: none; + padding: 6px 8px 2px; +} + +.ac-tree-new { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + height: 26px; + padding: 0 6px; + border: 0; + border-radius: 6px; + background: none; + font: inherit; + font-size: 12.5px; + color: #6b6b6b; + cursor: pointer; +} + +.ac-tree-new:hover { + background: #f1f1f1; + color: #262626; +} + +.ac-tree-new svg, +.ac-row-actions svg, +.ac-tree-dismiss svg { + width: 14px; + height: 14px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.ac-tree-field { + width: 100%; + height: 24px; + padding: 0 6px; + border: 1px solid #9a9a9a; + border-radius: 5px; + background: #fff; + font: inherit; + font-size: 12.5px; + color: #171717; + outline: none; +} + +/* A row is the open button plus, on files that can change, two actions at its end. */ +.ac-row-line { + position: relative; + height: 24px; + display: flex; + align-items: center; +} + +.ac-row-line.is-editing { + padding-right: 8px; +} + +.ac-row-actions { + position: absolute; + right: 4px; + top: 2px; + display: flex; + gap: 1px; +} + +.ac-row-actions button { + width: 20px; + height: 20px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 4px; + background: #f1f1f1; + color: #8a8a8a; + cursor: pointer; +} + +.ac-row-actions button:hover { + background: #e4e4e4; + color: #262626; +} + +@media (hover: hover) { + .ac-row-actions { + visibility: hidden; + } + .ac-row-line:hover .ac-row-actions, + .ac-row-actions:focus-within { + visibility: visible; + } +} + +/* Created here, in a source that cannot hold it upstream. */ +.ac-row-badge { + flex: none; + margin-left: 4px; + padding: 0 5px; + border-radius: 4px; + background: #f4f0e4; + color: #8a6d2f; + font-size: 10.5px; + line-height: 15px; +} + +.ac-tree-panel { + flex: none; + position: relative; + padding: 10px 12px; + border-top: 1px solid #eee; + font-size: 12px; + line-height: 1.5; + color: #3f3f3f; +} + +.ac-tree-panel p { + margin: 0 0 8px; + overflow-wrap: anywhere; +} + +.ac-tree-panel.is-error { + padding-right: 34px; + color: #b0303f; +} + +.ac-tree-panel.is-error p { + margin: 0; +} + +.ac-tree-panel-actions { + display: flex; + gap: 6px; +} + +.ac-tree-panel-actions button { + height: 26px; + padding: 0 10px; + border: 1px solid #ddd; + border-radius: 6px; + background: #fff; + font: inherit; + font-size: 12px; + color: #333; + cursor: pointer; +} + +.ac-tree-panel-actions button.is-danger { + border-color: #efc4ca; + color: #c03546; +} + +.ac-tree-panel-actions button.is-danger:hover:not(:disabled) { + border-color: #c03546; +} + +.ac-tree-dismiss { + position: absolute; + top: 8px; + right: 8px; + width: 20px; + height: 20px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 4px; + background: none; + color: #b0303f; + cursor: pointer; +} diff --git a/src/ui/Ink/DisplacedTray.tsx b/src/ui/Ink/DisplacedTray.tsx index 1cfb4e1..ded8f58 100644 --- a/src/ui/Ink/DisplacedTray.tsx +++ b/src/ui/Ink/DisplacedTray.tsx @@ -9,18 +9,25 @@ */ import { useInk } from '@/store/ink'; +import { useLayers } from '@/store/layers'; import { deleteAnnotation } from '@/db'; import type { Annotation } from '@/model/types'; import './ink.css'; -/** The line the note was written against, as it read at the time. */ +/** + * What to show for a displaced annotation. A typed note's own words are the thing worth + * keeping; for ink, the nearest code it was written beside, as it read at the time. + */ function original(annotation: Annotation): string { + if (annotation.kind === 'note' && annotation.text) return annotation.text.split('\n')[0]!.trim(); const { contextBefore, contextAfter } = annotation.anchor; return contextBefore.at(-1) ?? contextAfter[0] ?? ''; } export function DisplacedTray() { - const displaced = useInk((s) => s.displaced); + const all = useInk((s) => s.displaced); + const hidden = useLayers((s) => s.hidden); + const displaced = all.filter((a) => !hidden.has(a.layerId)); if (displaced.length === 0) return null; @@ -46,7 +53,11 @@ export function DisplacedTray() { {displaced.map((annotation) => (
  • line {annotation.anchor.line + 1} - {original(annotation) || '—'} + {annotation.kind === 'note' ? ( + {original(annotation) || '—'} + ) : ( + {original(annotation) || '—'} + )} diff --git a/src/ui/Ink/InkSurface.tsx b/src/ui/Ink/InkSurface.tsx index fe8e0ca..3f27906 100644 --- a/src/ui/Ink/InkSurface.tsx +++ b/src/ui/Ink/InkSurface.tsx @@ -30,6 +30,8 @@ import type { EditorView } from '@codemirror/view'; import type { InkPoint, StrokeStyle } from '@/model/types'; import { ERASER_WIDTHS, currentStrokeStyle, usePalette } from '@/store/palette'; import { useInk } from '@/store/ink'; +import { useLayers } from '@/store/layers'; +import { useNoteEditor } from '@/store/noteEditor'; import { canDraw, notePointerType, pressureOf, samplesOf } from '@/ink/pointer'; import { clear, drawEraserRing, drawStroke, prepare } from '@/ink/render'; import { @@ -118,6 +120,8 @@ export function InkSurface({ view, fileId, text }: Props) { const activeTool = usePalette((s) => s.activeTool); const revision = useInk((s) => s.revision); + // Hiding a layer is a repaint, not a reload. + const hidden = useLayers((s) => s.hidden); // The hot paths read these through refs: a pointermove must never depend on React having // re-rendered first. @@ -139,11 +143,13 @@ export function InkSurface({ view, fileId, text }: Props) { read: () => { const frame = frameOf(v, layer); const band = visibleLines(v); + const hiddenLayers = useLayers.getState().hidden; const items: Renderable[] = []; for (const { annotation, line } of useInk.getState().placed) { const geometry = annotation.geometry; if (!geometry || line < band.from || line > band.to) continue; + if (hiddenLayers.has(annotation.layerId)) continue; const top = lineTop(v, line); if (top === null) continue; @@ -171,7 +177,7 @@ export function InkSurface({ view, fileId, text }: Props) { // Repaint when the annotation set changes, and whenever the editor moves under it. useEffect(() => { repaintDry(); - }, [repaintDry, revision, fileId]); + }, [repaintDry, revision, fileId, hidden]); useEffect(() => { if (!view) return; @@ -235,6 +241,14 @@ export function InkSurface({ view, fileId, text }: Props) { if (top === null) return; const at = toLineSpace(frame, top, event.clientX, event.clientY); + + // Drawing on a hidden layer shows it. Ink that vanishes as it is written is worse than + // the layer coming back on under the pen. + const layers = useLayers.getState(); + if (layers.activeId && layers.hidden.has(layers.activeId)) { + void layers.setVisible(layers.activeId, true); + } + wet.current = { pointerId: event.pointerId, line, @@ -272,7 +286,7 @@ export function InkSurface({ view, fileId, text }: Props) { if (!stroke || stroke.points.length === 0) return; const { fileId: id, text: source } = latest.current; - const layerId = useInk.getState().layerId; + const layerId = await useLayers.getState().activeLayerId(); if (!layerId) return; const placed = await saveStroke({ @@ -298,10 +312,13 @@ export function InkSurface({ view, fileId, text }: Props) { const frame = frameOf(v, layer); const already = new Set(session.taken.map((p) => p.annotation.id)); + const hiddenLayers = useLayers.getState().hidden; for (const candidate of useInk.getState().placed) { const geometry = candidate.annotation.geometry; if (!geometry || already.has(candidate.annotation.id)) continue; + // Erasing ink you cannot see is destruction with no feedback. + if (hiddenLayers.has(candidate.annotation.layerId)) continue; const top = lineTop(v, candidate.line); if (top === null) continue; @@ -322,6 +339,24 @@ export function InkSurface({ view, fileId, text }: Props) { notePointerType(event.pointerType); + // The note tool writes text, not ink: a tap opens a note on that line, or the note under + // the tap. The chips sit beneath this surface, so they are found by asking the document + // what is at the point rather than by receiving the event themselves. + if (tool === 'note') { + if (!canDraw(event)) { + panning.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; + event.currentTarget.setPointerCapture(event.pointerId); + return; + } + const chip = document + .elementsFromPoint(event.clientX, event.clientY) + .find((el): el is HTMLElement => el instanceof HTMLElement && el.classList.contains('ac-note-chip')); + const editor = useNoteEditor.getState(); + if (chip?.dataset.noteId) void editor.edit(chip.dataset.noteId); + else void editor.start(lineAtClient(v, event.clientX, event.clientY)); + return; + } + // A palm, or a finger after the stylus has been picked up. It scrolls instead — done by // hand because `touch-action: none` is what stops the browser doing it for us while a // tool is armed. @@ -409,6 +444,11 @@ export function InkSurface({ view, fileId, text }: Props) { onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} + onMouseDown={(e) => { + // The mouse-down that follows a tap would move focus to the page and take it away + // from the note card the tap just opened. It has nothing else to do here. + if (latest.current.activeTool === 'note') e.preventDefault(); + }} />
  • ); diff --git a/src/ui/Ink/ink.css b/src/ui/Ink/ink.css index 25005c0..25a08cb 100644 --- a/src/ui/Ink/ink.css +++ b/src/ui/Ink/ink.css @@ -100,6 +100,15 @@ color: #555; } +.ac-displaced-note { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: #3d3d3d; +} + .ac-displaced-list button { flex: none; padding: 2px 7px; diff --git a/src/ui/Layers/LayersPanel.tsx b/src/ui/Layers/LayersPanel.tsx new file mode 100644 index 0000000..e617d07 --- /dev/null +++ b/src/ui/Layers/LayersPanel.tsx @@ -0,0 +1,235 @@ +/** + * The layers list, inside the palette's overflow menu. + * + * Three things per row and nothing more: whether the layer shows, what it is called, and + * whether it is the one you are writing on. The gist is explicit that layers "should not + * become a complicated design system" — they are independent annotation sets, so the panel + * is a list, not a tree, and there is no styling per layer beyond its name. + * + * Deleting asks once, inline, and offers to keep the annotations by moving them into + * another layer. That is the only reassignment there is until a lasso exists. + */ + +import { useEffect, useRef, useState } from 'react'; +import type { Layer } from '@/model/types'; +import { useInk } from '@/store/ink'; +import { useLayers } from '@/store/layers'; +import './layers.css'; + +function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? '' : 's'}`; +} + +function RenameField({ layer, onDone }: { layer: Layer; onDone: () => void }) { + const rename = useLayers((s) => s.rename); + const [value, setValue] = useState(layer.name); + const input = useRef(null); + const finished = useRef(false); + + useEffect(() => { + input.current?.focus(); + input.current?.select(); + }, []); + + const commit = () => { + if (finished.current) return; + finished.current = true; + void rename(layer.id, value); + onDone(); + }; + + return ( + setValue(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') { + // Cancel the rename, not the whole menu. + e.stopPropagation(); + finished.current = true; + onDone(); + } + }} + /> + ); +} + +function DeleteConfirm({ layer, onDone }: { layer: Layer; onDone: () => void }) { + const layers = useLayers((s) => s.layers); + const count = useLayers((s) => s.counts[layer.id]); + const remove = useLayers((s) => s.remove); + + const others = layers.filter((l) => l.id !== layer.id); + const [into, setInto] = useState(others[0]?.id ?? ''); + const [busy, setBusy] = useState(false); + + const run = async (target?: string) => { + setBusy(true); + await remove(layer.id, target); + useInk.getState().layerRemoved(layer.id, target); + onDone(); + }; + + // Unknown counts are treated as non-zero: offering to keep annotations that turn out not + // to exist costs a click, while assuming none exist could delete someone's work. + const empty = count === 0; + + return ( +
    +

    + {empty + ? `Delete “${layer.name}”?` + : `“${layer.name}” holds ${count === undefined ? 'annotations' : plural(count, 'annotation')}.`} +

    + + {!empty && others.length > 0 && ( +
    + + +
    + )} + +
    + + +
    +
    + ); +} + +export function LayersPanel() { + const layers = useLayers((s) => s.layers); + const activeId = useLayers((s) => s.activeId); + const counts = useLayers((s) => s.counts); + const setActive = useLayers((s) => s.setActive); + const setVisible = useLayers((s) => s.setVisible); + const create = useLayers((s) => s.create); + const refreshCounts = useLayers((s) => s.refreshCounts); + + const [renaming, setRenaming] = useState(null); + const [deleting, setDeleting] = useState(null); + + // Counts are read when the panel opens, not kept live: the panel is open for seconds, and + // counting on every stroke would put IndexedDB on the drawing path. + useEffect(() => { + void refreshCounts(); + }, [refreshCounts]); + + const onNew = async () => { + const layer = await create(); + if (layer) setRenaming(layer.id); + }; + + return ( +
    +

    Layers

    + +
      + {layers.map((layer) => { + const active = layer.id === activeId; + return ( +
    • +
      + + + {renaming === layer.id ? ( + setRenaming(null)} /> + ) : ( + + )} + + + {counts[layer.id] ?? ''} + + + + + +
      + + {deleting === layer.id && setDeleting(null)} />} +
    • + ); + })} +
    + + +
    + ); +} diff --git a/src/ui/Layers/layers.css b/src/ui/Layers/layers.css new file mode 100644 index 0000000..42d0e3d --- /dev/null +++ b/src/ui/Layers/layers.css @@ -0,0 +1,224 @@ +/* + * The layers list. It lives inside the palette's overflow menu, so it inherits that + * surface's material and only adds rows. + */ + +.ac-layers { + min-width: 268px; +} + +.ac-layer-list { + margin: 0; + padding: 0; + list-style: none; +} + +.ac-layer { + display: flex; + align-items: center; + gap: 2px; + height: 36px; + padding: 0 2px 0 0; + border-radius: 9px; +} + +.ac-layer:hover { + background: rgba(0, 0, 0, 0.04); +} + +.ac-palette .ac-layer-eye, +.ac-palette .ac-layer-action { + width: 30px; + height: 30px; + flex: none; + display: grid; + place-items: center; + border-radius: 7px; + color: #6b6b70; +} + +.ac-palette .ac-layer-eye:hover, +.ac-palette .ac-layer-action:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.06); + color: #1c1c1e; +} + +.ac-layer svg, +.ac-layer-new svg { + width: 17px; + height: 17px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* The two row actions stay out of the way until the row is pointed at. On a touch screen + there is no hover, so they simply stay visible. */ +@media (hover: hover) { + .ac-layer .ac-layer-action { + opacity: 0; + } + .ac-layer:hover .ac-layer-action, + .ac-layer .ac-layer-action:focus-visible { + opacity: 1; + } +} + +.ac-palette .ac-layer-name { + flex: 1; + min-width: 0; + height: 30px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 6px; + border-radius: 7px; + font: inherit; + font-size: 13px; + color: #2c2c2e; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* The active layer is marked by a filled dot and weight, nothing louder. */ +.ac-layer-dot { + width: 7px; + height: 7px; + flex: none; + border-radius: 50%; + box-shadow: inset 0 0 0 1.5px #b8b8bf; +} + +.ac-layer.is-active .ac-layer-dot { + background: #1c1c1e; + box-shadow: none; +} + +.ac-layer.is-active .ac-layer-name { + font-weight: 600; + color: #111; +} + +.ac-layer.is-hidden .ac-layer-name, +.ac-layer.is-hidden .ac-layer-count { + color: #a5a5aa; +} + +.ac-layer-count { + min-width: 22px; + padding-right: 2px; + text-align: right; + color: #8e8e93; + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.ac-layer-rename { + flex: 1; + min-width: 0; + height: 28px; + padding: 0 7px; + border: 1px solid #c7c7cc; + border-radius: 7px; + background: #fff; + font: inherit; + font-size: 13px; + outline: none; +} + +.ac-layer-rename:focus { + border-color: #1c1c1e; +} + +.ac-palette .ac-layer-new { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 34px; + margin-top: 2px; + padding: 0 8px; + border-radius: 9px; + font: inherit; + font-size: 13px; + color: #3a3a3c; +} + +.ac-palette .ac-layer-new:hover { + background: rgba(0, 0, 0, 0.05); +} + +/* ---- delete ------------------------------------------------------------- */ + +.ac-layer-confirm { + margin: 2px 0 6px; + padding: 10px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 10px; + background: rgba(255, 255, 255, 0.75); + font-size: 12.5px; + color: #3a3a3c; +} + +.ac-layer-confirm p { + margin: 0 0 8px; + line-height: 1.4; +} + +.ac-layer-move { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.ac-layer-move label { + display: flex; + align-items: center; + gap: 6px; + cursor: default; +} + +.ac-layer-move select { + max-width: 130px; + height: 26px; + border: 1px solid #d1d1d6; + border-radius: 6px; + background: #fff; + font: inherit; + font-size: 12.5px; +} + +.ac-palette .ac-layer-confirm button { + height: 26px; + padding: 0 9px; + border: 1px solid #d1d1d6; + border-radius: 6px; + background: #fff; + font: inherit; + font-size: 12px; + color: #2c2c2e; +} + +.ac-palette .ac-layer-confirm button:hover:not(:disabled) { + border-color: #a1a1a6; +} + +.ac-palette .ac-layer-confirm button.is-danger { + border-color: #e7b3ba; + color: #c03546; +} + +.ac-palette .ac-layer-confirm button.is-danger:hover:not(:disabled) { + border-color: #c03546; +} + +.ac-layer-confirm-actions { + display: flex; + gap: 6px; +} diff --git a/src/ui/Notes/NoteComposer.tsx b/src/ui/Notes/NoteComposer.tsx new file mode 100644 index 0000000..8f2fa37 --- /dev/null +++ b/src/ui/Notes/NoteComposer.tsx @@ -0,0 +1,162 @@ +/** + * The card a note is written in. + * + * It opens just below the line the note belongs to — above it, if there is no room — and + * follows that line as the document scrolls, so it never reads as a dialog floating free of + * the code. A tap anywhere else keeps what was typed; Escape is the only way to throw it + * away, and an emptied note is a deleted one. + */ + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { EditorView } from '@codemirror/view'; +import { lineTop } from '@/ink/space'; +import { useNoteEditor } from '@/store/noteEditor'; +import { usePalette } from '@/store/palette'; +import './notes.css'; + +const GAP = 6; +const MARGIN = 10; + +interface Props { + view: EditorView | null; +} + +export function NoteComposer({ view }: Props) { + const draft = useNoteEditor((s) => s.draft); + const setText = useNoteEditor((s) => s.setText); + const commit = useNoteEditor((s) => s.commit); + const cancel = useNoteEditor((s) => s.cancel); + const remove = useNoteEditor((s) => s.remove); + + const card = useRef(null); + const field = useRef(null); + const [place, setPlace] = useState<{ top: number; left: number } | null>(null); + + const line = draft?.line ?? null; + const key = draft ? `${draft.fileId}:${draft.annotationId ?? `new:${draft.line}`}` : null; + + /** Below the line when it fits, above it when it does not, and never off the page. */ + const position = useCallback(() => { + const element = card.current; + const area = element?.offsetParent as HTMLElement | null; + if (!view || !element || !area || line === null) return; + + const top = lineTop(view, line); + if (top === null) return; + + const box = area.getBoundingClientRect(); + const content = view.contentDOM.getBoundingClientRect(); + const height = view.defaultLineHeight; + const lineY = view.documentTop + top - box.top; + + const below = lineY + height + GAP; + const above = lineY - GAP - element.offsetHeight; + const fitsBelow = below + element.offsetHeight <= box.height - MARGIN; + const y = fitsBelow || above < MARGIN ? below : above; + + setPlace({ + top: Math.min(Math.max(y, MARGIN), box.height - element.offsetHeight - MARGIN), + left: Math.min( + Math.max(content.left - box.left + 24, MARGIN), + box.width - element.offsetWidth - MARGIN, + ), + }); + }, [view, line]); + + useLayoutEffect(() => { + if (key) position(); + }, [key, position]); + + // Follow the line while the document scrolls under the card. + useEffect(() => { + if (!view || !key) return; + const scroller = view.scrollDOM; + scroller.addEventListener('scroll', position, { passive: true }); + window.addEventListener('resize', position); + return () => { + scroller.removeEventListener('scroll', position); + window.removeEventListener('resize', position); + }; + }, [view, key, position]); + + // Focus once the card has been placed. Before that it is `visibility: hidden`, and a hidden + // element cannot take focus — the call would simply be ignored. + const focusedFor = useRef(null); + const placed = place !== null; + useEffect(() => { + if (!key || !placed || focusedFor.current === key) return; + focusedFor.current = key; + const element = field.current; + element?.focus(); + // Editing an existing note puts the caret at its end, ready to add to it. + element?.setSelectionRange(element.value.length, element.value.length); + }, [key, placed]); + + // A tap anywhere else keeps the note. A tap on another note's chip opens that one, which + // commits this one on the way. + useEffect(() => { + if (!key) return; + const onPointerDown = (e: PointerEvent) => { + const target = e.target as HTMLElement | null; + if (!target || card.current?.contains(target) || target.closest('.ac-note-chip')) return; + // With the note tool armed, a tap on the document starts the next note — the ink + // layer commits this one as part of that, so committing here too would race it. + if (usePalette.getState().activeTool === 'note' && target.closest('.ac-ink-layer')) return; + void commit(); + }; + document.addEventListener('pointerdown', onPointerDown, true); + return () => document.removeEventListener('pointerdown', onPointerDown, true); + }, [key, commit]); + + if (!draft) return null; + + return ( +
    +
    + {draft.annotationId ? 'Note' : 'New note'} · line {draft.line + 1} +
    + +