From 0a22b3c4063004fa10adf45f4d9d81b79d54e83b Mon Sep 17 00:00:00 2001 From: Raj Date: Tue, 6 Oct 2026 23:58:29 +0530 Subject: [PATCH 01/10] Plan Phase 4: layers, bookmarks, typed notes, search The data model has carried Layer, Bookmark, kind 'note' and layerId since Phase 0, and Phases 2 and 3 never exercised them. So Phase 4 needs no new store, no schema bump and no second anchoring path: notes ride in the same placed list as strokes, bookmarks resolve through the same ladder, and hiding a layer is a render-time filter rather than a reload. The document also lists the calls made without a conversation - where layers live, how a note is created, where it is drawn - with the cheapest way to reverse each one. --- PHASE_4_ORGANISATION.md | 189 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 PHASE_4_ORGANISATION.md 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. From 4080d526607da1e2df9ec6706411ca4091cac713 Mon Sep 17 00:00:00 2001 From: Raj Date: Wed, 7 Oct 2026 00:00:11 +0530 Subject: [PATCH 02/10] Add goTo, so bookmarks and search have somewhere to send you One navigation primitive for everything in Phase 4 that points at code: open the file if it is not open, then land on the line. A jump is not a restored reading position, and they now behave differently. Restoring puts the line back at the top of the screen, where it was being read from. A jump centres the line and moves the cursor onto it, so the active-line highlight shows where you arrived - otherwise a jump into the middle of a long file leaves you hunting for the line you asked for. goTo takes an optional anchor and resolves it against the file's text once it has loaded. A bookmark or note in a file that changed since it was written still lands on its code; when the ladder gives up, the stored line is used as given. Checked in headless Chrome: a jump into another file centres line 91 and lights it, and a jump within the open file does the same. --- src/app/App.tsx | 2 ++ src/store/session.ts | 56 ++++++++++++++++++++++++++++++++++++-- src/ui/Reader/CodeView.tsx | 19 ++++++++++--- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 61c2bfa..105f00d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -35,6 +35,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); @@ -229,6 +230,7 @@ export function App() { path={activeFile.path} text={content} initialLine={pendingLine} + initialIsJump={pendingIsJump} onInitialLineUsed={consumePendingLine} onLineChange={rememberLine} onViewReady={setView} diff --git a/src/store/session.ts b/src/store/session.ts index 70f199a..8ae82ed 100644 --- a/src/store/session.ts +++ b/src/store/session.ts @@ -10,7 +10,8 @@ */ import { create } from 'zustand'; -import type { FileEntry, Source } from '@/model/types'; +import { resolveAnchor, toLines } from '@/model/anchor'; +import type { Anchor, FileEntry, Source } from '@/model/types'; import { getReadingPosition, latestReadingPosition, putReadingPosition } from '@/db'; import { forgetSource, @@ -64,6 +65,12 @@ interface SessionState { saveError: Failure | null; /** Line to scroll to once the document is rendered; cleared when consumed. */ pendingLine: number | null; + /** + * Whether `pendingLine` is a jump rather than a restored reading position. A jump is + * centred and takes the cursor, so the line you asked for is the one that is lit; a + * reading position goes back to the top of the screen, where it was read from. + */ + pendingIsJump: boolean; recents: Source[]; @@ -82,12 +89,30 @@ interface SessionState { toggleDirectory: (path: string) => void; openFile: (fileId: string) => Promise; + /** + * Take the reader to a line, opening its file first if need be. + * + * `anchor`, when given, is resolved against the file's text once it is loaded, so a + * bookmark or note in a file that changed since it was written still lands on its code. + * When it cannot be resolved, `line` is used as given. + */ + goTo: (fileId: string, line: number, anchor?: Anchor) => Promise; consumePendingLine: () => void; rememberLine: (line: number) => void; } let positionTimer: ReturnType | undefined; +/** A jump waiting for its file to load. Consumed by `openFile`. */ +let jump: { fileId: string; line: number; anchor?: Anchor } | null = null; + +/** Where a jump should land in `text`: the anchor's line if it still resolves, else `line`. */ +function landing(text: string, line: number, anchor?: Anchor): number { + if (!anchor) return line; + const resolved = resolveAnchor(anchor, toLines(text)); + return resolved.resolved === 'unresolved' ? line : resolved.line; +} + export const useSession = create()((set, get) => { /** Shared tail of every open path, so the four entry points cannot drift apart. */ async function adopt(opened: { @@ -111,6 +136,7 @@ export const useSession = create()((set, get) => { content: null, fileError: null, pendingLine: null, + pendingIsJump: false, }); void get().loadRecents(); @@ -150,6 +176,7 @@ export const useSession = create()((set, get) => { fileError: null, loadingFile: false, pendingLine: null, + pendingIsJump: false, mode: 'read', dirty: false, saving: false, @@ -184,6 +211,7 @@ export const useSession = create()((set, get) => { fileError: null, warnings: [], pendingLine: null, + pendingIsJump: false, }); void get().loadRecents(); }, @@ -265,10 +293,16 @@ export const useSession = create()((set, get) => { // A slower read for a file the user has since navigated away from must not win. if (get().activeFile?.id !== fileId) return; + const target = jump?.fileId === fileId ? jump : null; + jump = null; + set({ content: text, loadingFile: false, - pendingLine: position?.line ?? null, + pendingLine: target + ? landing(text, target.line, target.anchor) + : (position?.line ?? null), + pendingIsJump: target !== null, dirty: false, saveError: null, }); @@ -278,7 +312,23 @@ export const useSession = create()((set, get) => { } }, - consumePendingLine: () => set({ pendingLine: null }), + goTo: async (fileId, line, anchor) => { + const { activeFile, content } = get(); + + if (activeFile?.id === fileId) { + // Already open: the text in hand is the text to resolve against, edits included. + set({ + pendingLine: content === null ? line : landing(content, line, anchor), + pendingIsJump: true, + }); + return; + } + + jump = anchor ? { fileId, line, anchor } : { fileId, line }; + await get().openFile(fileId); + }, + + consumePendingLine: () => set({ pendingLine: null, pendingIsJump: false }), rememberLine: (line) => { const { activeFile, source } = get(); diff --git a/src/ui/Reader/CodeView.tsx b/src/ui/Reader/CodeView.tsx index 5f31832..b78635a 100644 --- a/src/ui/Reader/CodeView.tsx +++ b/src/ui/Reader/CodeView.tsx @@ -38,6 +38,8 @@ interface Props { text: string; /** Scroll here once, on open. */ initialLine: number | null; + /** Centre `initialLine` and put the cursor on it, rather than restoring it to the top. */ + initialIsJump?: boolean; onInitialLineUsed: () => void; onLineChange: (line: number) => void; /** @@ -91,6 +93,7 @@ export function CodeView({ path, text, initialLine, + initialIsJump = false, onInitialLineUsed, onLineChange, onViewReady, @@ -188,12 +191,20 @@ export function CodeView({ if (!editor || initialLine === null) return; const line = Math.min(Math.max(initialLine + 1, 1), editor.state.doc.lines); - editor.dispatch({ - effects: EditorView.scrollIntoView(editor.state.doc.line(line).from, { y: 'start' }), - }); + const from = editor.state.doc.line(line).from; + + editor.dispatch( + initialIsJump + ? { + // The cursor goes with a jump, so the active-line highlight marks where you landed. + selection: { anchor: from }, + effects: EditorView.scrollIntoView(from, { y: 'center' }), + } + : { effects: EditorView.scrollIntoView(from, { y: 'start' }) }, + ); handlers.current.onInitialLineUsed(); - }, [fileId, initialLine]); + }, [fileId, initialLine, initialIsJump]); return
; } From bbc22161e9114e145f1e58fb7e319f463ccee58d Mon Sep 17 00:00:00 2001 From: Raj Date: Wed, 7 Oct 2026 00:05:40 +0530 Subject: [PATCH 03/10] Build layers: create, rename, show, hide, delete or merge Layers live in the palette's overflow menu, which the plan reserved for them; the menu also takes over hiding the palette, which is what the dots used to do on their own. One layer per source is active, persisted in meta, and every new stroke is written into it. Visibility is a render-time filter. Hiding a layer is a repaint: nothing is reloaded, nothing in the database changes, and the undo stack is left alone, so showing it again cannot lose anything. Hidden ink is not drawn and cannot be erased - rubbing out something you cannot see is destruction with no feedback. Drawing on a hidden active layer turns it back on rather than laying down ink that vanishes as it is written. Deleting asks once, inline, and offers to move the layer's annotations into another layer instead. That is the plan's "reassign", at the only granularity there is without a lasso. The merge is one transaction, so a failure halfway cannot leave annotations in a layer the user was told is gone. Removing a layer also drops the ink undo stack: an entry holds whole annotations, and replaying one would resurrect it in the deleted layer. The layers store now owns the default layer. ensureDefaultLayer shares an in-flight call per source, because the store and the ink loader used to race on a fresh source and could each write a "Notes" layer. Checked in headless Chrome: draw, add a layer and write on it, hide one and see only its ink go, erase across hidden ink and lose nothing, merge on delete, reload and find visibility and the active layer as left. --- src/app/App.tsx | 17 +- src/db/db.test.ts | 46 ++++++ src/db/index.ts | 30 ++++ src/ink/store.ts | 47 ++++-- src/store/ink.ts | 34 ++-- src/store/layers.ts | 187 ++++++++++++++++++++++ src/store/palette.ts | 19 ++- src/ui/Ink/DisplacedTray.tsx | 5 +- src/ui/Ink/InkSurface.tsx | 20 ++- src/ui/Layers/LayersPanel.tsx | 235 ++++++++++++++++++++++++++++ src/ui/Layers/layers.css | 224 ++++++++++++++++++++++++++ src/ui/ToolPalette/OverflowMenu.tsx | 43 +++++ src/ui/ToolPalette/ToolPalette.tsx | 12 +- src/ui/ToolPalette/palette.css | 56 +++++++ 14 files changed, 934 insertions(+), 41 deletions(-) create mode 100644 src/store/layers.ts create mode 100644 src/ui/Layers/LayersPanel.tsx create mode 100644 src/ui/Layers/layers.css create mode 100644 src/ui/ToolPalette/OverflowMenu.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 105f00d..f391322 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -15,6 +15,7 @@ import type { EditorView, ViewUpdate } from '@codemirror/view'; import { isInkTool, usePalette } from '@/store/palette'; import { lineMapper } from '@/ink/reflow'; import { useInk } from '@/store/ink'; +import { useLayers } from '@/store/layers'; import { useSession } from '@/store/session'; import { FileTree } from '@/ui/FileTree/FileTree'; import { DisplacedTray } from '@/ui/Ink/DisplacedTray'; @@ -57,6 +58,13 @@ export function App() { const canUndo = useInk((s) => s.stacks.undo.length > 0); const canRedo = useInk((s) => s.stacks.redo.length > 0); + // Layers 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); + else useLayers.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 @@ -72,7 +80,7 @@ export function App() { } if (content === null || loadedFor.current === fileId) return; loadedFor.current = fileId; - void openInk(fileId, source.id, content); + void openInk(fileId, content); }, [fileId, content, source, openInk, closeInk]); /** @@ -95,7 +103,12 @@ export function App() { 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; diff --git a/src/db/db.test.ts b/src/db/db.test.ts index fc7de97..d12f3f6 100644 --- a/src/db/db.test.ts +++ b/src/db/db.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createAnchor, toLines } from '@/model/anchor'; import type { Annotation, Bookmark, FileEntry, Layer, Source } from '@/model/types'; import { + countLayerAnnotations, deleteFile, deleteLayer, deleteSource, @@ -14,6 +15,7 @@ import { listFiles, listLayers, listSources, + mergeLayer, putAnnotation, putBookmark, putFile, @@ -163,6 +165,50 @@ describe('cascading deletes', () => { }); }); +describe('layers', () => { + it('counts a layer’s annotations across every file', async () => { + await putAnnotation(annotation('a1', 'f1', 'l1')); + await putAnnotation(annotation('a2', 'f2', 'l1')); + await putAnnotation(annotation('a3', 'f1', 'l2')); + + expect(await countLayerAnnotations('l1')).toBe(2); + expect(await countLayerAnnotations('l2')).toBe(1); + expect(await countLayerAnnotations('empty')).toBe(0); + }); + + it('merges one layer into another and removes the emptied layer', async () => { + await putLayer(layer('from', 's1')); + await putLayer(layer('into', 's1', 1)); + await putAnnotation(annotation('a1', 'f1', 'from')); + await putAnnotation(annotation('a2', 'f2', 'from')); + await putAnnotation(annotation('a3', 'f1', 'into')); + + expect(await mergeLayer('from', 'into')).toBe(2); + + expect((await listLayers('s1')).map((l) => l.id)).toEqual(['into']); + expect(await countLayerAnnotations('into')).toBe(3); + expect(await countLayerAnnotations('from')).toBe(0); + }); + + it('leaves the moved annotations otherwise exactly as they were', async () => { + await putAnnotation(annotation('a1', 'f1', 'from')); + + await mergeLayer('from', 'into'); + + const [moved] = await listAnnotations('f1'); + expect(moved?.layerId).toBe('into'); + expect(moved?.geometry?.points).toEqual([{ x: 0, y: 0, p: 0.5 }]); + expect(moved?.anchor.contextBefore).toEqual(['function a() {']); + }); + + it('merges an empty layer without complaint', async () => { + await putLayer(layer('from', 's1')); + + expect(await mergeLayer('from', 'into')).toBe(0); + expect(await listLayers('s1')).toHaveLength(0); + }); +}); + describe('export', () => { it('captures everything a user wrote about one codebase', async () => { await putSource(source('s1')); diff --git a/src/db/index.ts b/src/db/index.ts index de3726d..c5d68ba 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -220,6 +220,36 @@ export async function deleteLayer(id: string): Promise { await tx.done; } +/** How many annotations a layer holds, across every file. Shown before deleting it. */ +export async function countLayerAnnotations(layerId: string): Promise { + return (await db()).countFromIndex('annotations', 'by-layer', layerId); +} + +/** + * Move every annotation in one layer into another, then delete the emptied layer. Returns + * how many moved. + * + * One transaction, because the alternative failure is the bad one: a merge that dies + * halfway would leave annotations in a layer the user has been told is gone. + */ +export async function mergeLayer(fromId: string, intoId: string): Promise { + const database = await db(); + const tx = database.transaction(['layers', 'annotations'], 'readwrite'); + const index = tx.objectStore('annotations').index('by-layer'); + const now = Date.now(); + + let moved = 0; + // Each update moves the record out of this cursor's key range, so nothing is visited twice. + for (let cursor = await index.openCursor(fromId); cursor; cursor = await cursor.continue()) { + await cursor.update({ ...cursor.value, layerId: intoId, updatedAt: now }); + moved++; + } + + await tx.objectStore('layers').delete(fromId); + await tx.done; + return moved; +} + // --------------------------------------------------------------------------- // Annotations // --------------------------------------------------------------------------- diff --git a/src/ink/store.ts b/src/ink/store.ts index 13db36e..7bca57e 100644 --- a/src/ink/store.ts +++ b/src/ink/store.ts @@ -30,24 +30,39 @@ export interface Loaded { displaced: Annotation[]; } +/** Calls already in flight, so two at once for the same source create one layer, not two. */ +const creating = new Map>(); + /** - * Phase 2 has no layer UI, but every annotation needs a layer, so one is created on demand. - * Phase 4 turns this list into something the user can see and manage. + * Every annotation needs a layer, so a source that has none gets one, silently, the first + * time anything asks. The layers panel manages the list from there. + * + * Concurrent calls share one creation. Without that, the layers store and anything else + * that opens a fresh source at the same moment would each find the list empty and each + * write a "Notes" layer. */ -export async function ensureDefaultLayer(sourceId: string): Promise { - const existing = await listLayers(sourceId); - if (existing.length > 0) return existing[0]!; - - const layer: Layer = { - id: crypto.randomUUID(), - sourceId, - name: 'Notes', - visible: true, - order: 0, - updatedAt: Date.now(), - }; - await putLayer(layer); - return layer; +export function ensureDefaultLayer(sourceId: string): Promise { + const pending = creating.get(sourceId); + if (pending) return pending; + + const work = (async () => { + const existing = await listLayers(sourceId); + if (existing.length > 0) return existing[0]!; + + const layer: Layer = { + id: crypto.randomUUID(), + sourceId, + name: 'Notes', + visible: true, + order: 0, + updatedAt: Date.now(), + }; + await putLayer(layer); + return layer; + })().finally(() => creating.delete(sourceId)); + + creating.set(sourceId, work); + return work; } /** diff --git a/src/store/ink.ts b/src/store/ink.ts index 1786c72..8fc585e 100644 --- a/src/store/ink.ts +++ b/src/store/ink.ts @@ -13,7 +13,6 @@ import { create } from 'zustand'; import type { Annotation } from '@/model/types'; import * as history from '@/ink/history'; import { - ensureDefaultLayer, eraseStrokes, loadForFile, reanchorStrokes, @@ -23,7 +22,6 @@ import { interface InkState { fileId: string | null; - layerId: string | null; placed: Placed[]; /** Annotations the anchor ladder could not justify a position for. */ displaced: Annotation[]; @@ -31,7 +29,7 @@ interface InkState { /** Bumped whenever the drawn set changes, so the canvas knows to repaint. */ revision: number; - open: (fileId: string, sourceId: string, text: string) => Promise; + open: (fileId: string, text: string) => Promise; close: () => void; /** After a stroke has been saved. */ @@ -52,17 +50,23 @@ interface InkState { /** After a save, recapture every anchor against the text that was written. */ reanchor: (text: string) => Promise; + + /** + * A layer was deleted, or merged into `into`. Brings the working set in line with what the + * database now holds, and drops the undo history: an entry holds whole annotations, and + * replaying one would put it back into a layer the user has just removed. + */ + layerRemoved: (layerId: string, into?: string) => void; } export const useInk = create()((set, get) => ({ fileId: null, - layerId: null, placed: [], displaced: [], stacks: history.emptyStacks(), revision: 0, - open: async (fileId, sourceId, text) => { + open: async (fileId, text) => { // Clear first: a stale file's ink must never be visible over a new file's code, not even // for the frame or two the load takes. set({ @@ -73,16 +77,12 @@ export const useInk = create()((set, get) => ({ revision: get().revision + 1, }); - const [layer, loaded] = await Promise.all([ - ensureDefaultLayer(sourceId), - loadForFile(fileId, text), - ]); + const loaded = await loadForFile(fileId, text); // The user may have moved on while this was in flight. if (get().fileId !== fileId) return; set((s) => ({ - layerId: layer.id, placed: loaded.placed, displaced: loaded.displaced, revision: s.revision + 1, @@ -137,6 +137,20 @@ export const useInk = create()((set, get) => ({ set((s) => ({ placed: updated, revision: s.revision + 1 })); }, + layerRemoved: (layerId, into) => + set((s) => { + const relabel = (a: T): T | null => + a.layerId !== layerId ? a : into ? { ...a, layerId: into } : null; + + const placed = s.placed.flatMap((p) => { + const annotation = relabel(p.annotation); + return annotation ? [{ ...p, annotation }] : []; + }); + const displaced = s.displaced.flatMap((a) => relabel(a) ?? []); + + return { placed, displaced, stacks: history.emptyStacks(), revision: s.revision + 1 }; + }), + undo: async () => { const popped = history.popUndo(get().stacks); if (!popped) return; diff --git a/src/store/layers.ts b/src/store/layers.ts new file mode 100644 index 0000000..47d17c1 --- /dev/null +++ b/src/store/layers.ts @@ -0,0 +1,187 @@ +/** + * Layers: independent annotation sets over the same code. + * + * "My Notes", "Questions", "Exam revision" — the gist's example. One list per source, and + * exactly one of them active: that is the layer every new stroke and note is written into. + * + * Visibility is a render-time filter. Hiding a layer does not reload anything, touch the + * database's annotations, or disturb the undo stack; the ink surface simply skips what is + * hidden on its next repaint. That keeps the toggle instant on a file with a thousand + * strokes, and it means showing the layer again cannot lose anything. + */ + +import { create } from 'zustand'; +import type { Layer } from '@/model/types'; +import { + countLayerAnnotations, + deleteLayer, + getMeta, + listLayers, + mergeLayer, + putLayer, + setMeta, +} from '@/db'; +import { ensureDefaultLayer } from '@/ink/store'; + +interface LayersState { + sourceId: string | null; + /** In display order. Never empty once loaded — a source always has a layer. */ + layers: Layer[]; + /** Where new annotations go. */ + activeId: string | null; + /** Ids of hidden layers. A new Set on every change, so subscribers can compare by identity. */ + hidden: ReadonlySet; + /** Annotations per layer, across every file. Refreshed when someone asks. */ + counts: Readonly>; + + load: (sourceId: string) => Promise; + unload: () => void; + /** The active layer's id, waiting for a load in progress rather than answering `null`. */ + activeLayerId: () => Promise; + + create: (name?: string) => Promise; + rename: (id: string, name: string) => Promise; + setVisible: (id: string, visible: boolean) => Promise; + setActive: (id: string) => Promise; + /** + * Delete a layer. With `into`, its annotations move to that layer instead of going with + * it — the plan's "reassign", at the only granularity there is without a lasso. + */ + remove: (id: string, into?: string) => Promise; + refreshCounts: () => Promise; +} + +const activeKey = (sourceId: string) => `activeLayer:${sourceId}`; + +function hiddenOf(layers: Layer[]): ReadonlySet { + return new Set(layers.filter((l) => !l.visible).map((l) => l.id)); +} + +/** "Layer 2", "Layer 3", … — the first number not already taken. */ +export function nextLayerName(layers: Pick[]): string { + const taken = new Set(layers.map((l) => l.name.trim().toLowerCase())); + for (let n = layers.length + 1; ; n++) { + const name = `Layer ${n}`; + if (!taken.has(name.toLowerCase())) return name; + } +} + +let loading: Promise | null = null; + +export const useLayers = create()((set, get) => { + /** Write one layer and replace it in the list. */ + async function save(layer: Layer) { + await putLayer(layer); + set((s) => { + const layers = s.layers.map((l) => (l.id === layer.id ? layer : l)); + return { layers, hidden: hiddenOf(layers) }; + }); + } + + return { + sourceId: null, + layers: [], + activeId: null, + hidden: new Set(), + counts: {}, + + load: (sourceId) => { + const work = (async () => { + const fallback = await ensureDefaultLayer(sourceId); + const [layers, stored] = await Promise.all([ + listLayers(sourceId), + getMeta(activeKey(sourceId)), + ]); + + // A source opened and closed again while this was in flight is not ours to fill. + if (get().sourceId !== sourceId) return; + + const activeId = layers.some((l) => l.id === stored) ? stored! : (layers[0] ?? fallback).id; + set({ layers, activeId, hidden: hiddenOf(layers), counts: {} }); + })(); + + set({ sourceId, layers: [], activeId: null, hidden: new Set(), counts: {} }); + const tracked: Promise = work.finally(() => { + if (loading === tracked) loading = null; + }); + loading = tracked; + return work; + }, + + unload: () => set({ sourceId: null, layers: [], activeId: null, hidden: new Set(), counts: {} }), + + activeLayerId: async () => { + if (loading) await loading; + return get().activeId; + }, + + create: async (name) => { + const { sourceId, layers } = get(); + if (!sourceId) return null; + + const layer: Layer = { + id: crypto.randomUUID(), + sourceId, + name: name?.trim() || nextLayerName(layers), + visible: true, + order: Math.max(-1, ...layers.map((l) => l.order)) + 1, + updatedAt: Date.now(), + }; + + await putLayer(layer); + const next = [...get().layers, layer]; + set((s) => ({ layers: next, hidden: hiddenOf(next), counts: { ...s.counts, [layer.id]: 0 } })); + + // A layer is made to be written on. Making it active is what the user means. + await get().setActive(layer.id); + return layer; + }, + + rename: async (id, name) => { + const layer = get().layers.find((l) => l.id === id); + const trimmed = name.trim(); + if (!layer || !trimmed || trimmed === layer.name) return; + await save({ ...layer, name: trimmed, updatedAt: Date.now() }); + }, + + setVisible: async (id, visible) => { + const layer = get().layers.find((l) => l.id === id); + if (!layer || layer.visible === visible) return; + await save({ ...layer, visible, updatedAt: Date.now() }); + }, + + setActive: async (id) => { + const { sourceId, layers } = get(); + if (!sourceId || !layers.some((l) => l.id === id)) return; + set({ activeId: id }); + await setMeta(activeKey(sourceId), id); + }, + + remove: async (id, into) => { + const { layers, activeId } = get(); + // Every annotation needs a home, so the last layer stays. + if (layers.length <= 1 || !layers.some((l) => l.id === id)) return; + if (into === id || (into && !layers.some((l) => l.id === into))) return; + + if (into) await mergeLayer(id, into); + else await deleteLayer(id); + + const next = get().layers.filter((l) => l.id !== id); + const counts = { ...get().counts }; + if (into) counts[into] = (counts[into] ?? 0) + (counts[id] ?? 0); + delete counts[id]; + + set({ layers: next, hidden: hiddenOf(next), counts }); + if (activeId === id) await get().setActive(into ?? next[0]!.id); + }, + + refreshCounts: async () => { + const { layers, sourceId } = get(); + const entries = await Promise.all( + layers.map(async (l) => [l.id, await countLayerAnnotations(l.id)] as const), + ); + if (get().sourceId !== sourceId) return; + set({ counts: Object.fromEntries(entries) }); + }, + }; +}); diff --git a/src/store/palette.ts b/src/store/palette.ts index 415c6c0..d3154c3 100644 --- a/src/store/palette.ts +++ b/src/store/palette.ts @@ -55,6 +55,8 @@ interface PaletteState { /** The width/opacity popover, opened by tapping the already-selected tool. */ attributesOpen: boolean; + /** The `⋯` menu: layers, export, hiding the palette. */ + overflowOpen: boolean; dock: Dock; collapsed: boolean; @@ -65,6 +67,8 @@ interface PaletteState { setWidthStep: (step: number) => void; setOpacity: (opacity: number) => void; closeAttributes: () => void; + toggleOverflow: () => void; + closeOverflow: () => void; setDock: (dock: Dock) => void; toggleCollapsed: () => void; } @@ -82,6 +86,7 @@ export const usePalette = create()( settings: structuredClone(DEFAULTS), eraserWidthStep: 1, attributesOpen: false, + overflowOpen: false, dock: 'bottom', collapsed: false, @@ -93,8 +98,8 @@ export const usePalette = create()( selectTool: (tool) => set((s) => s.activeTool === tool - ? { attributesOpen: !s.attributesOpen } - : { activeTool: tool, attributesOpen: false }, + ? { attributesOpen: !s.attributesOpen, overflowOpen: false } + : { activeTool: tool, attributesOpen: false, overflowOpen: false }, ), clearTool: () => set({ activeTool: null, attributesOpen: false }), @@ -119,13 +124,17 @@ export const usePalette = create()( }, closeAttributes: () => set({ attributesOpen: false }), + // One popover at a time: the menu and the attributes would otherwise stack over the code. + toggleOverflow: () => set((s) => ({ overflowOpen: !s.overflowOpen, attributesOpen: false })), + closeOverflow: () => set({ overflowOpen: false }), setDock: (dock) => set({ dock }), - toggleCollapsed: () => set((s) => ({ collapsed: !s.collapsed, attributesOpen: false })), + toggleCollapsed: () => + set((s) => ({ collapsed: !s.collapsed, attributesOpen: false, overflowOpen: false })), }), { name: 'annotatecode:palette', - // Deliberately not persisting `activeTool` or `attributesOpen`: a returning user - // should land in reading mode, not mid-stroke. + // Deliberately not persisting `activeTool` or the popovers: a returning user should + // land in reading mode, not mid-stroke. partialize: (s) => ({ settings: s.settings, eraserWidthStep: s.eraserWidthStep, diff --git a/src/ui/Ink/DisplacedTray.tsx b/src/ui/Ink/DisplacedTray.tsx index 1cfb4e1..b3a5fbb 100644 --- a/src/ui/Ink/DisplacedTray.tsx +++ b/src/ui/Ink/DisplacedTray.tsx @@ -9,6 +9,7 @@ */ import { useInk } from '@/store/ink'; +import { useLayers } from '@/store/layers'; import { deleteAnnotation } from '@/db'; import type { Annotation } from '@/model/types'; import './ink.css'; @@ -20,7 +21,9 @@ function original(annotation: Annotation): string { } 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; diff --git a/src/ui/Ink/InkSurface.tsx b/src/ui/Ink/InkSurface.tsx index fe8e0ca..110d59f 100644 --- a/src/ui/Ink/InkSurface.tsx +++ b/src/ui/Ink/InkSurface.tsx @@ -30,6 +30,7 @@ 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 { canDraw, notePointerType, pressureOf, samplesOf } from '@/ink/pointer'; import { clear, drawEraserRing, drawStroke, prepare } from '@/ink/render'; import { @@ -118,6 +119,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 +142,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 +176,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 +240,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 +285,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 +311,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; 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/ToolPalette/OverflowMenu.tsx b/src/ui/ToolPalette/OverflowMenu.tsx new file mode 100644 index 0000000..9a2d132 --- /dev/null +++ b/src/ui/ToolPalette/OverflowMenu.tsx @@ -0,0 +1,43 @@ +/** + * The palette's `⋯` menu. + * + * The plan reserved this for "Layers, Code Space toggle, export" — the things you reach for + * now and then, kept off the tray so the tray stays short enough for a phone. Hiding the + * palette lives here too; it used to be what `⋯` did on its own. + */ + +import { useEffect, useRef } from 'react'; +import { usePalette } from '@/store/palette'; +import { LayersPanel } from '@/ui/Layers/LayersPanel'; + +export function OverflowMenu() { + const menu = useRef(null); + const closeOverflow = usePalette((s) => s.closeOverflow); + const toggleCollapsed = usePalette((s) => s.toggleCollapsed); + + // A tap anywhere else closes it, the way every popover on a tablet behaves. Captured so it + // runs before the tap does whatever else it was going to do. + useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + const target = e.target as HTMLElement | null; + if (!target || menu.current?.contains(target)) return; + // The `⋯` button toggles the menu itself; closing here would reopen it on click. + if (target.closest('.ac-more')) return; + closeOverflow(); + }; + document.addEventListener('pointerdown', onPointerDown, true); + return () => document.removeEventListener('pointerdown', onPointerDown, true); + }, [closeOverflow]); + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/src/ui/ToolPalette/ToolPalette.tsx b/src/ui/ToolPalette/ToolPalette.tsx index b4f7a54..7b2fea9 100644 --- a/src/ui/ToolPalette/ToolPalette.tsx +++ b/src/ui/ToolPalette/ToolPalette.tsx @@ -17,6 +17,7 @@ import { useRef, useState } from 'react'; import type { Tool } from '@/model/types'; import { isInkTool, SWATCHES, usePalette, type Dock } from '@/store/palette'; import { AttributesPopover } from './AttributesPopover'; +import { OverflowMenu } from './OverflowMenu'; import { ToolInstrument } from './ToolInstrument'; import './palette.css'; @@ -40,7 +41,6 @@ export function ToolPalette({ canUndo = false, canRedo = false, onUndo, onRedo } const tool = state.activeTool; const ink = isInkTool(tool) ? state.settings[tool] : null; - const vertical = state.dock === 'left' || state.dock === 'right'; if (state.collapsed) { return ( @@ -63,7 +63,7 @@ export function ToolPalette({ canUndo = false, canRedo = false, onUndo, onRedo } * which would only ever end up covering code. */ function onPointerDown(e: React.PointerEvent) { - if ((e.target as HTMLElement).closest('button, input, label')) return; + if ((e.target as HTMLElement).closest('button, input, label, select, .ac-overflow')) return; dragged.current = false; setDragging(true); e.currentTarget.setPointerCapture(e.pointerId); @@ -102,6 +102,7 @@ export function ToolPalette({ canUndo = false, canRedo = false, onUndo, onRedo } onPointerCancel={onPointerUp} > {state.attributesOpen && } + {state.overflowOpen && }
@@ -179,9 +180,10 @@ export function ToolPalette({ canUndo = false, canRedo = false, onUndo, onRedo } From 9678c41c09561a364e1fa1bd083b91512a1948bc Mon Sep 17 00:00:00 2001 From: Raj Date: Wed, 7 Oct 2026 00:13:26 +0530 Subject: [PATCH 05/10] Build bookmarks: a ribbon in the margin, a list in the sidebar Two ways to set one. The ribbon button in the toolbar marks the cursor's line when the cursor is on screen, and otherwise the top line of what is showing - a reader who scrolled without clicking means "here", not wherever the cursor was left, which on a fresh file is line 1. A narrow margin left of the line numbers takes a click on any line, shows a grey ghost under the pointer, and is the way in on a tablet. A new bookmark is named after its own line. "export async function runJob(job) {" says more than "Bookmark 3" and costs nothing to accept; it can be renamed in the list, and coloured from the palette's swatches. Bookmarks are anchored, not numbered. Each one already carried an Anchor, and now it is used: resolved through the ladder when its file opens, carried across edits by the same line mapper as ink, and re-anchored on save. One the ladder cannot place is listed as "moved?" rather than pointed at a line it no longer describes. The margin's ribbons are a CodeMirror range set, so between store updates CodeMirror maps them through edits itself. The sidebar gains Files and Bookmarks views, the way a PDF reader's does, instead of more buttons in the one strip of chrome the code shares the screen with. Bookmark gets the optional updatedAt every other record has. Checked in headless Chrome: set from the toolbar and the margin, clear from the margin, mark the top line after scrolling away, rename and recolour from the list, jump to it from another file, type two lines above it and watch the ribbon follow, save, reload, and find it on the moved line. --- src/app/App.tsx | 71 ++++++++-- src/app/app.css | 3 +- src/model/types.ts | 2 + src/organise/bookmarks.test.ts | 105 +++++++++++++++ src/organise/bookmarks.ts | 92 +++++++++++++ src/store/bookmarks.ts | 174 ++++++++++++++++++++++++ src/store/session.ts | 6 +- src/ui/Bookmarks/BookmarksList.tsx | 210 +++++++++++++++++++++++++++++ src/ui/Bookmarks/bookmarks.css | 125 +++++++++++++++++ src/ui/Reader/CodeView.tsx | 23 +++- src/ui/Reader/bookmarkGutter.ts | 105 +++++++++++++++ src/ui/Reader/theme.ts | 21 +++ src/ui/Sidebar/Sidebar.tsx | 59 ++++++++ src/ui/Sidebar/sidebar.css | 134 ++++++++++++++++++ 14 files changed, 1114 insertions(+), 16 deletions(-) create mode 100644 src/organise/bookmarks.test.ts create mode 100644 src/organise/bookmarks.ts create mode 100644 src/store/bookmarks.ts create mode 100644 src/ui/Bookmarks/BookmarksList.tsx create mode 100644 src/ui/Bookmarks/bookmarks.css create mode 100644 src/ui/Reader/bookmarkGutter.ts create mode 100644 src/ui/Sidebar/Sidebar.tsx create mode 100644 src/ui/Sidebar/sidebar.css diff --git a/src/app/App.tsx b/src/app/App.tsx index f391322..6ba86db 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -10,19 +10,22 @@ * 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 { useBookmarks } from '@/store/bookmarks'; import { useInk } from '@/store/ink'; import { useLayers } from '@/store/layers'; import { useSession } from '@/store/session'; -import { FileTree } from '@/ui/FileTree/FileTree'; import { DisplacedTray } from '@/ui/Ink/DisplacedTray'; import { InkSurface } from '@/ui/Ink/InkSurface'; 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() { @@ -58,11 +61,16 @@ export function App() { const canUndo = useInk((s) => s.stacks.undo.length > 0); const canRedo = useInk((s) => s.stacks.redo.length > 0); - // Layers belong to the source, not the file: load them once per codebase. + // 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); - else useLayers.getState().unload(); + 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. @@ -75,14 +83,39 @@ 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, 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], + ); + + 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. * @@ -94,9 +127,9 @@ 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], ); @@ -185,6 +218,22 @@ export function App() { )} {activeFile && {languageName(activeFile.path) ?? 'Text'}} + {activeFile && content !== null && ( + + )} + {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/Reader/CodeView.tsx b/src/ui/Reader/CodeView.tsx index b78635a..8a48234 100644 --- a/src/ui/Reader/CodeView.tsx +++ b/src/ui/Reader/CodeView.tsx @@ -28,6 +28,7 @@ import { keymap, lineNumbers, } from '@codemirror/view'; +import { bookmarkGutter, setBookmarkMarks, type BookmarkMark } from './bookmarkGutter'; import { grammarFor } from './language'; import { paperTheme, paperHighlight } from './theme'; @@ -55,6 +56,10 @@ interface Props { * layer moves its strokes by running its lines through `update.changes`. */ onDocChange?: (text: string, update: ViewUpdate) => void; + /** Bookmarked lines in this document, drawn as ribbons in the margin. */ + bookmarks?: readonly BookmarkMark[]; + /** A click in the bookmark margin, on a 0-indexed line. */ + onToggleBookmark?: (line: number) => void; } const language = new Compartment(); @@ -68,8 +73,10 @@ function readingMode(): Extension[] { return [EditorState.readOnly.of(true), EditorView.editable.of(false)]; } -function baseExtensions(): Extension[] { +function baseExtensions(onToggleBookmark: (line: number) => void): Extension[] { return [ + // Before the line numbers, so the ribbons sit in the outer margin like a folded corner. + bookmarkGutter(onToggleBookmark), lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(), @@ -99,13 +106,15 @@ export function CodeView({ onViewReady, editable, onDocChange, + bookmarks, + onToggleBookmark, }: Props) { const host = useRef(null); const view = useRef(null); // Callbacks are read through a ref so that changing one never tears down the editor. - const handlers = useRef({ onLineChange, onInitialLineUsed, onViewReady, onDocChange }); - handlers.current = { onLineChange, onInitialLineUsed, onViewReady, onDocChange }; + const handlers = useRef({ onLineChange, onInitialLineUsed, onViewReady, onDocChange, onToggleBookmark }); + handlers.current = { onLineChange, onInitialLineUsed, onViewReady, onDocChange, onToggleBookmark }; // The editor owns the text while a file is open, so the `text` prop is a seed rather than // a binding. Reading it through a ref keeps a keystroke from round-tripping through the @@ -121,7 +130,7 @@ export function CodeView({ state: EditorState.create({ doc: '', extensions: [ - ...baseExtensions(), + ...baseExtensions((line) => handlers.current.onToggleBookmark?.(line)), language.of([]), writable.of(readingMode()), EditorView.updateListener.of((update) => { @@ -178,6 +187,12 @@ export function CodeView({ // Not keyed on `text`: see `seed` above. }, [fileId, path]); + // Redraw the ribbons when the store's picture changes. After the document swap above, + // because a ribbon set built against the previous file's text would be dropped on its lines. + useEffect(() => { + view.current?.dispatch({ effects: setBookmarkMarks.of(bookmarks ?? []) }); + }, [bookmarks, fileId]); + // Turn editing on and off without rebuilding the editor. useEffect(() => { view.current?.dispatch({ diff --git a/src/ui/Reader/bookmarkGutter.ts b/src/ui/Reader/bookmarkGutter.ts new file mode 100644 index 0000000..32fcd1a --- /dev/null +++ b/src/ui/Reader/bookmarkGutter.ts @@ -0,0 +1,105 @@ +/** + * The bookmark gutter. + * + * A narrow column left of the line numbers, like the margin of a book you would fold a + * corner into: a ribbon on every bookmarked line, a faint one under the pointer, and a click + * sets or clears a bookmark there. It is the tablet-friendly way in — the toolbar button + * needs you to have put the cursor somewhere first. + * + * The markers are a CodeMirror range set, so between updates from the store they are mapped + * through edits by CodeMirror itself and never lag the text by a frame. + */ + +import { RangeSet, StateEffect, StateField, type Extension, type Text } from '@codemirror/state'; +import { type EditorView, GutterMarker, gutter } from '@codemirror/view'; +import { targetLine } from '@/organise/bookmarks'; + +export interface BookmarkMark { + /** 0-indexed. */ + line: number; + color: string; + name: string; +} + +class Ribbon extends GutterMarker { + constructor( + readonly color: string, + readonly name: string, + ) { + super(); + } + + override eq(other: GutterMarker): boolean { + return other instanceof Ribbon && other.color === this.color && other.name === this.name; + } + + override toDOM(): Node { + const el = document.createElement('span'); + el.className = 'ac-ribbon'; + el.style.setProperty('--ribbon', this.color); + el.title = this.name; + return el; + } +} + +/** Replace the whole set. The store is the source of truth; this is its picture. */ +export const setBookmarkMarks = StateEffect.define(); + +function build(doc: Text, marks: readonly BookmarkMark[]): RangeSet { + const ranges = marks + .filter((m) => m.line >= 0 && m.line < doc.lines) + .map((m) => new Ribbon(m.color, m.name).range(doc.line(m.line + 1).from)) + .sort((a, b) => a.from - b.from); + return RangeSet.of(ranges, true); +} + +const marksField = StateField.define>({ + create: () => RangeSet.empty, + update(marks, tr) { + let next = marks.map(tr.changes); + for (const effect of tr.effects) { + if (effect.is(setBookmarkMarks)) next = build(tr.state.doc, effect.value); + } + return next; + }, +}); + +/** Keeps the column its width when no line has a bookmark yet. */ +const spacer = new (class extends GutterMarker { + override toDOM() { + const el = document.createElement('span'); + el.className = 'ac-ribbon-spacer'; + return el; + } +})(); + +export function bookmarkGutter(onToggle: (line: number) => void): Extension { + return [ + marksField, + gutter({ + class: 'ac-bookmark-gutter', + markers: (view) => view.state.field(marksField), + initialSpacer: () => spacer, + domEventHandlers: { + mousedown(view, block) { + onToggle(view.state.doc.lineAt(block.from).number - 1); + return true; + }, + }, + }), + ]; +} + +/** + * The line a "bookmark this" in the toolbar means, read from the editor: the cursor's line + * if it is on screen, otherwise the top line of what is showing. See `targetLine`. + */ +export function bookmarkLineIn(view: EditorView): number { + const { doc } = view.state; + const box = view.scrollDOM.getBoundingClientRect(); + const lineAtY = (y: number) => + doc.lineAt(view.posAtCoords({ x: box.left + 1, y }, false)).number - 1; + + const cursor = doc.lineAt(view.state.selection.main.head).number - 1; + return targetLine(cursor, { from: lineAtY(box.top + 1), to: lineAtY(box.bottom - 1) }); +} diff --git a/src/ui/Reader/theme.ts b/src/ui/Reader/theme.ts index cb98c7a..d8e5474 100644 --- a/src/ui/Reader/theme.ts +++ b/src/ui/Reader/theme.ts @@ -52,6 +52,27 @@ export const paperTheme = EditorView.theme({ // Line numbers are a column; proportional digits make them shimmer as you scroll. fontVariantNumeric: 'tabular-nums', }, + // The bookmark margin. A ribbon is the one mark here that carries colour, because it is + // one the reader chose; under the pointer a grey ghost shows that a click will place one. + '.ac-bookmark-gutter .cm-gutterElement': { + width: '16px', + position: 'relative', + cursor: 'pointer', + }, + '.ac-ribbon, .ac-bookmark-gutter .cm-gutterElement:hover::before': { + position: 'absolute', + left: '5px', + top: '50%', + width: '9px', + height: '13px', + transform: 'translateY(-50%)', + clipPath: 'polygon(0 0, 100% 0, 100% 100%, 50% 74%, 0 100%)', + }, + '.ac-ribbon': { backgroundColor: 'var(--ribbon)' }, + '.ac-bookmark-gutter .cm-gutterElement:hover::before': { + content: '""', + backgroundColor: '#d4d4d4', + }, '.cm-foldGutter .cm-gutterElement': { padding: '0 3px', color: '#d8d8d8', diff --git a/src/ui/Sidebar/Sidebar.tsx b/src/ui/Sidebar/Sidebar.tsx new file mode 100644 index 0000000..2858b26 --- /dev/null +++ b/src/ui/Sidebar/Sidebar.tsx @@ -0,0 +1,59 @@ +/** + * The left column. + * + * A PDF reader's sidebar switches between pages, an outline and bookmarks; this one switches + * between the files and what you have marked in them. Views rather than more toolbar + * buttons, because the toolbar is the one strip of chrome the code has to share the screen + * with. + */ + +import { useState } from 'react'; +import { useBookmarks } from '@/store/bookmarks'; +import { BookmarksList } from '@/ui/Bookmarks/BookmarksList'; +import { FileTree } from '@/ui/FileTree/FileTree'; +import './sidebar.css'; + +type View = 'files' | 'bookmarks'; + +const VIEWS: Array<{ id: View; label: string }> = [ + { id: 'files', label: 'Files' }, + { id: 'bookmarks', label: 'Bookmarks' }, +]; + +export function Sidebar() { + const [view, setView] = useState('files'); + const bookmarkCount = useBookmarks((s) => s.all.length); + + return ( +
+
+ {VIEWS.map(({ id, label }) => ( + + ))} +
+ +
+ {view === 'files' ? : } +
+
+ ); +} diff --git a/src/ui/Sidebar/sidebar.css b/src/ui/Sidebar/sidebar.css new file mode 100644 index 0000000..f0d75d5 --- /dev/null +++ b/src/ui/Sidebar/sidebar.css @@ -0,0 +1,134 @@ +/* The sidebar's frame: a row of view tabs over whichever view is showing. */ + +.ac-side { + height: 100%; + display: flex; + flex-direction: column; + min-width: 260px; +} + +.ac-side-tabs { + flex: none; + display: flex; + gap: 2px; + padding: 8px 8px 6px; + border-bottom: 1px solid #f0f0f0; +} + +.ac-side-tab { + height: 28px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: none; + font: inherit; + font-size: 12.5px; + color: #6b6b6b; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ac-side-tab:hover { + background: #f1f1f1; + color: #262626; +} + +.ac-side-tab.is-active { + background: #ececec; + color: #111; + font-weight: 550; +} + +.ac-side-count { + min-width: 16px; + padding: 0 4px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.07); + font-size: 11px; + font-weight: 500; + color: #555; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.ac-side-body { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +/* The file tree windows its own rows and must be the scroller itself. */ +.ac-side-body.is-files { + overflow: hidden; +} + +/* A file path heading a group of things marked in it. */ +.ac-side-group { + margin: 12px 0 2px; + padding: 0 14px; + font-size: 11.5px; + font-weight: 500; + color: #8a8a8a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ac-side-action { + width: 26px; + height: 26px; + flex: none; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 6px; + background: none; + color: #8a8a8a; + cursor: pointer; +} + +.ac-side-action:hover { + background: #e6e6e6; + color: #262626; +} + +.ac-side-action svg { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +@media (hover: hover) { + .ac-side-action { + opacity: 0; + } + li:hover .ac-side-action, + .ac-side-action:focus-visible { + opacity: 1; + } +} + +.ac-side-empty { + padding: 22px 18px; + font-size: 12.5px; + line-height: 1.55; + color: #8a8a8a; +} + +.ac-side-empty p { + margin: 0 0 6px; +} + +.ac-side-empty-title { + font-size: 13px; + font-weight: 600; + color: #3f3f3f; +} From 986fccbf3bcf411a8c46d2c157d1ef022b235d4c Mon Sep 17 00:00:00 2001 From: Raj Date: Wed, 7 Oct 2026 00:22:08 +0530 Subject: [PATCH 06/10] Build typed notes: a Note tool, chips at the end of the line A fourth tool in the palette. Arm it and tap a line to write a note on it; tap a note - with the tool armed or with none at all - to open it again. Keeping note-taking in the palette means it works the same way with a stylus as everything else does. A note is an annotation like a stroke: kind 'note', in the active layer, in the same placed list. So it is carried across an edit by the same line mapper, re-anchored by the same save, hidden with its layer, and sent to the displaced tray when the ladder gives up - none of that needed writing twice. It is not on the ink undo stack, where undo means "the last stroke"; a note has its own Delete, and emptying one deletes it. Notes are drawn as CodeMirror widget decorations after the code on their line, not as an overlay positioned by hand, so they scroll with the text by construction and there is nothing to keep in sync. The chip's height is held inside the line box: a chip that grew its line would move every stroke anchored below it, and the check for that is now part of the browser run. The card a note is written in opens under its line, or over it when there is no room, and follows the line as the document scrolls. A tap anywhere else keeps what was typed - tapping a second line commits the first note on the way - and Escape is the only way to throw it away. Leaving a file commits too, before the ink moves on to the next one. Two collisions found on the way. The mouse-down that follows a tap would move focus to the page and away from the card the tap had just opened, so the drawing surface now swallows it while the Note tool is armed. And Ctrl+Z inside a note field undid the last stroke instead of the last keystroke; the ink's undo now leaves text fields alone. The displaced tray shows a note's own words rather than the code beside it - for typed notes the words are the thing worth keeping. Checked in headless Chrome: write a note, see the chip on its line with no line height changed, edit it from the chip, move to another line mid-note and keep both, discard with Escape, delete by emptying, hide and show its layer, Ctrl+Z inside the field, type two lines above it and watch it follow, save, reload, and find it on the moved line. --- src/app/App.tsx | 35 +++++- src/app/app.css | 5 + src/ink/store.ts | 46 ++++++++ src/model/types.ts | 9 +- src/organise/notes.test.ts | 59 ++++++++++ src/organise/notes.ts | 27 +++++ src/store/ink.ts | 23 ++++ src/store/noteEditor.ts | 136 +++++++++++++++++++++ src/store/palette.ts | 18 ++- src/ui/Ink/DisplacedTray.tsx | 12 +- src/ui/Ink/InkSurface.tsx | 24 ++++ src/ui/Ink/ink.css | 9 ++ src/ui/Notes/NoteComposer.tsx | 162 ++++++++++++++++++++++++++ src/ui/Notes/notes.css | 91 +++++++++++++++ src/ui/Reader/CodeView.tsx | 40 ++++++- src/ui/Reader/noteWidgets.ts | 85 ++++++++++++++ src/ui/Reader/theme.ts | 23 ++++ src/ui/ToolPalette/ToolInstrument.tsx | 15 +++ src/ui/ToolPalette/ToolPalette.tsx | 27 +++-- 19 files changed, 820 insertions(+), 26 deletions(-) create mode 100644 src/organise/notes.test.ts create mode 100644 src/organise/notes.ts create mode 100644 src/store/noteEditor.ts create mode 100644 src/ui/Notes/NoteComposer.tsx create mode 100644 src/ui/Notes/notes.css create mode 100644 src/ui/Reader/noteWidgets.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 6ba86db..7e16af4 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -15,12 +15,15 @@ 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 { 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'; @@ -111,6 +114,25 @@ export function App() { [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); @@ -148,6 +170,8 @@ export function App() { } 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'); if (accel && e.key.toLowerCase() === 's') { e.preventDefault(); void useSession.getState().save(); @@ -156,7 +180,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; @@ -279,7 +303,11 @@ export function App() { -
+
{fileError ? (

{fileError.message}

@@ -300,8 +328,11 @@ export function App() { onDocChange={onDocChange} bookmarks={ribbons} onToggleBookmark={toggleBookmark} + notes={notes} + onOpenNote={openNote} /> + ) : ( diff --git a/src/app/app.css b/src/app/app.css index 0c4c437..5c2c1ee 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -269,6 +269,11 @@ body { crosshair; } +/* The note tool places text, so it wears the text cursor. */ +.ac-document.is-noting { + cursor: text; +} + /* ---- empty states ------------------------------------------------------ */ .ac-empty { diff --git a/src/ink/store.ts b/src/ink/store.ts index 7bca57e..4159e61 100644 --- a/src/ink/store.ts +++ b/src/ink/store.ts @@ -129,6 +129,52 @@ export async function saveStroke(params: { return { annotation, line }; } +/** + * A typed note, written on a line. + * + * Stored as an annotation like any stroke — same anchor, same layer, same store — so it is + * carried across edits, re-anchored on save and sent to the displaced tray by exactly the + * code that does those things for ink. It has no geometry; its `style` carries only the + * colour of its edge. + */ +export async function saveNote(params: { + fileId: string; + layerId: string; + /** The document text, for capturing the anchor. */ + text: string; + /** 0-indexed. */ + line: number; + body: string; + color: string; +}): Promise { + const { fileId, layerId, text, line, body, color } = params; + const now = Date.now(); + + const annotation: Annotation = { + id: crypto.randomUUID(), + layerId, + fileId, + kind: 'note', + anchor: createAnchor(toLines(text), line), + text: body, + style: { tool: 'pen', color, width: 0, opacity: 1 }, + createdAt: now, + updatedAt: now, + schemaVersion: 1, + }; + + await putAnnotation(annotation); + void requestPersistence(); + return { annotation, line }; +} + +/** Rewrite a note's text. Its anchor and layer stay as they are. */ +export async function rewriteNote(annotation: Annotation, body: string): Promise { + const next: Annotation = { ...annotation, text: body, updatedAt: Date.now() }; + await putAnnotation(next); + return next; +} + export async function eraseStrokes(ids: string[]): Promise { if (ids.length > 0) await deleteAnnotations(ids); } diff --git a/src/model/types.ts b/src/model/types.ts index 1efc735..d0cdc87 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -53,10 +53,13 @@ export interface Layer { updatedAt: number; } -export type Tool = 'pen' | 'highlighter' | 'eraser'; +export type Tool = 'pen' | 'highlighter' | 'eraser' | 'note'; -/** Tools that lay down ink. The eraser is a tool but never a stroke style. */ -export type InkTool = Exclude; +/** + * Tools that lay down ink. The eraser is a tool but never a stroke style, and the note tool + * writes typed text rather than strokes. + */ +export type InkTool = 'pen' | 'highlighter'; export type AnchorResolution = 'exact' | 'moved' | 'unresolved'; diff --git a/src/organise/notes.test.ts b/src/organise/notes.test.ts new file mode 100644 index 0000000..0f3dd09 --- /dev/null +++ b/src/organise/notes.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { createAnchor, toLines } from '@/model/anchor'; +import type { Annotation } from '@/model/types'; +import type { Placed } from '@/ink/store'; +import { chipText, noteMarks } from './notes'; + +const anchor = createAnchor(toLines('a\nb\nc'), 1); + +function placed(id: string, kind: Annotation['kind'], layerId: string, line: number, text?: string): Placed { + const annotation: Annotation = { + id, + layerId, + fileId: 'f', + kind, + anchor, + style: { tool: 'pen', color: '#2f5fd0', width: 0, opacity: 1 }, + createdAt: 1, + updatedAt: 1, + schemaVersion: 1, + }; + if (text !== undefined) annotation.text = text; + return { annotation, line }; +} + +describe('noteMarks', () => { + const set = [ + placed('n1', 'note', 'mine', 3, 'first'), + placed('s1', 'ink', 'mine', 3), + placed('n2', 'note', 'hidden', 7, 'not shown'), + placed('n3', 'note', 'mine', 9, 'third'), + ]; + + it('keeps notes on visible layers, at their placed lines', () => { + expect(noteMarks(set, new Set(['hidden']))).toEqual([ + { id: 'n1', line: 3, text: 'first', color: '#2f5fd0' }, + { id: 'n3', line: 9, text: 'third', color: '#2f5fd0' }, + ]); + }); + + it('never turns a stroke into a note', () => { + expect(noteMarks(set, new Set()).map((m) => m.id)).not.toContain('s1'); + }); + + it('shows everything when nothing is hidden', () => { + expect(noteMarks(set, new Set())).toHaveLength(3); + }); +}); + +describe('chipText', () => { + it.each([ + ['one line', 'one line'], + [' padded ', 'padded'], + ['first\nsecond', 'first'], + ['\n\n after blanks\nmore', 'after blanks'], + ['', ''], + ])('summarises %j as %j', (text, expected) => { + expect(chipText(text)).toBe(expected); + }); +}); diff --git a/src/organise/notes.ts b/src/organise/notes.ts new file mode 100644 index 0000000..495731e --- /dev/null +++ b/src/organise/notes.ts @@ -0,0 +1,27 @@ +/** + * Typed notes, as data: which ones to show, and how a chip summarises one. + */ + +import type { Placed } from '@/ink/store'; + +export interface NoteMark { + id: string; + /** 0-indexed. */ + line: number; + text: string; + color: string; +} + +/** The notes in a placed set that belong on screen: kind 'note', on a visible layer. */ +export function noteMarks(placed: readonly Placed[], hidden: ReadonlySet): NoteMark[] { + return placed.flatMap(({ annotation, line }) => + annotation.kind === 'note' && !hidden.has(annotation.layerId) + ? [{ id: annotation.id, line, text: annotation.text ?? '', color: annotation.style.color }] + : [], + ); +} + +/** The chip shows a note's first non-blank line; the whole note is in its title and editor. */ +export function chipText(text: string): string { + return text.split('\n').find((l) => l.trim())?.trim() ?? ''; +} diff --git a/src/store/ink.ts b/src/store/ink.ts index 8fc585e..d40e848 100644 --- a/src/store/ink.ts +++ b/src/store/ink.ts @@ -57,6 +57,13 @@ interface InkState { * replaying one would put it back into a layer the user has just removed. */ layerRemoved: (layerId: string, into?: string) => void; + + /** + * A typed note was written or rewritten. Notes are not on the undo stack: undo means "the + * last stroke", and a note has its own Delete. + */ + noteSaved: (note: Placed) => void; + noteRemoved: (id: string) => void; } export const useInk = create()((set, get) => ({ @@ -151,6 +158,22 @@ export const useInk = create()((set, get) => ({ return { placed, displaced, stacks: history.emptyStacks(), revision: s.revision + 1 }; }), + noteSaved: (note) => + set((s) => { + const exists = s.placed.some((p) => p.annotation.id === note.annotation.id); + const placed = exists + ? s.placed.map((p) => (p.annotation.id === note.annotation.id ? note : p)) + : [...s.placed, note]; + return { placed, revision: s.revision + 1 }; + }), + + noteRemoved: (id) => + set((s) => ({ + placed: s.placed.filter((p) => p.annotation.id !== id), + displaced: s.displaced.filter((a) => a.id !== id), + revision: s.revision + 1, + })), + undo: async () => { const popped = history.popUndo(get().stacks); if (!popped) return; diff --git a/src/store/noteEditor.ts b/src/store/noteEditor.ts new file mode 100644 index 0000000..b4485b9 --- /dev/null +++ b/src/store/noteEditor.ts @@ -0,0 +1,136 @@ +/** + * The note being written or edited, if any. + * + * One at a time, and opening another commits the one in hand — tapping a second line with + * the note tool should not silently throw away what you were typing on the first. + */ + +import { create } from 'zustand'; +import { deleteAnnotation } from '@/db'; +import { rewriteNote, saveNote } from '@/ink/store'; +import { useInk } from '@/store/ink'; +import { useLayers } from '@/store/layers'; +import { usePalette } from '@/store/palette'; +import { useSession } from '@/store/session'; + +export interface Draft { + fileId: string; + /** 0-indexed. Where the note is, or will be. */ + line: number; + /** `null` for a note not yet written. */ + annotationId: string | null; + text: string; + /** What the note said when the editor opened, to tell an edit from a glance. */ + original: string; + /** + * The document as it read when a new note was started. The note is anchored against it, + * so a commit that happens while the reader is already moving to another file still + * anchors to the right text. + */ + docText: string; +} + +interface NoteEditorState { + draft: Draft | null; + + /** Begin a new note on a line of the open file. */ + start: (line: number) => Promise; + /** Open an existing note. */ + edit: (annotationId: string) => Promise; + setText: (text: string) => void; + /** Save what is typed. An empty note is a deleted one. */ + commit: () => Promise; + cancel: () => void; + remove: () => Promise; +} + +export const useNoteEditor = create()((set, get) => ({ + draft: null, + + start: async (line) => { + await get().commit(); + const { activeFile, content } = useSession.getState(); + if (!activeFile || content === null) return; + set({ + draft: { fileId: activeFile.id, line, annotationId: null, text: '', original: '', docText: content }, + }); + }, + + edit: async (annotationId) => { + if (get().draft?.annotationId === annotationId) return; + await get().commit(); + + const placed = useInk.getState().placed.find((p) => p.annotation.id === annotationId); + if (!placed || placed.annotation.kind !== 'note') return; + + const text = placed.annotation.text ?? ''; + set({ + draft: { + fileId: placed.annotation.fileId, + line: placed.line, + annotationId, + text, + original: text, + docText: '', + }, + }); + }, + + setText: (text) => set((s) => (s.draft ? { draft: { ...s.draft, text } } : {})), + + commit: async () => { + const draft = get().draft; + if (!draft) return; + set({ draft: null }); + + const body = draft.text.trim() ? draft.text.replace(/\s+$/, '') : ''; + // The ink working set may move on to another file while the write is in flight. The + // database write still happens; only the in-memory update is for the file it belongs to. + const stillOpen = () => useInk.getState().fileId === draft.fileId; + + if (draft.annotationId) { + const placed = useInk.getState().placed.find((p) => p.annotation.id === draft.annotationId); + if (!placed) return; + + if (!body) { + await deleteAnnotation(draft.annotationId); + if (stillOpen()) useInk.getState().noteRemoved(draft.annotationId); + return; + } + if (body === draft.original) return; + + const annotation = await rewriteNote(placed.annotation, body); + if (stillOpen()) useInk.getState().noteSaved({ annotation, line: placed.line }); + return; + } + + if (!body) return; + + const layerId = await useLayers.getState().activeLayerId(); + if (!layerId) return; + + // A note written onto a hidden layer would vanish as it is saved; show the layer instead. + const layers = useLayers.getState(); + if (layers.hidden.has(layerId)) void layers.setVisible(layerId, true); + + const placed = await saveNote({ + fileId: draft.fileId, + layerId, + text: draft.docText, + line: draft.line, + body, + color: usePalette.getState().noteColor, + }); + if (stillOpen()) useInk.getState().noteSaved(placed); + }, + + cancel: () => set({ draft: null }), + + remove: async () => { + const draft = get().draft; + set({ draft: null }); + if (!draft?.annotationId) return; + await deleteAnnotation(draft.annotationId); + if (useInk.getState().fileId === draft.fileId) useInk.getState().noteRemoved(draft.annotationId); + }, +})); diff --git a/src/store/palette.ts b/src/store/palette.ts index d3154c3..caab4b8 100644 --- a/src/store/palette.ts +++ b/src/store/palette.ts @@ -52,6 +52,8 @@ interface PaletteState { activeTool: Tool | null; settings: Record; eraserWidthStep: number; + /** Notes have a colour and nothing else — it is the hairline on the note's edge. */ + noteColor: string; /** The width/opacity popover, opened by tapping the already-selected tool. */ attributesOpen: boolean; @@ -73,6 +75,9 @@ interface PaletteState { toggleCollapsed: () => void; } +/** Blue, which the swatch list above already calls "note blue". */ +export const NOTE_COLOR = '#2f5fd0'; + const DEFAULTS: Record = { pen: { color: '#2f5fd0', widthStep: 1, opacity: 1 }, // A highlighter that is not translucent is a marker pen, and it would bury the code. @@ -85,6 +90,7 @@ export const usePalette = create()( activeTool: null, settings: structuredClone(DEFAULTS), eraserWidthStep: 1, + noteColor: NOTE_COLOR, attributesOpen: false, overflowOpen: false, dock: 'bottom', @@ -98,7 +104,8 @@ export const usePalette = create()( selectTool: (tool) => set((s) => s.activeTool === tool - ? { attributesOpen: !s.attributesOpen, overflowOpen: false } + ? // A note has no width or opacity, so there is nothing for a second tap to open. + { attributesOpen: tool === 'note' ? false : !s.attributesOpen, overflowOpen: false } : { activeTool: tool, attributesOpen: false, overflowOpen: false }, ), @@ -106,6 +113,7 @@ export const usePalette = create()( setColor: (color) => { const tool = get().activeTool; + if (tool === 'note') return set({ noteColor: color }); if (!isInkTool(tool)) return; set((s) => ({ settings: { ...s.settings, [tool]: { ...s.settings[tool], color } } })); }, @@ -138,6 +146,7 @@ export const usePalette = create()( partialize: (s) => ({ settings: s.settings, eraserWidthStep: s.eraserWidthStep, + noteColor: s.noteColor, dock: s.dock, collapsed: s.collapsed, }), @@ -149,6 +158,13 @@ export function isInkTool(tool: Tool | null): tool is InkTool { return tool === 'pen' || tool === 'highlighter'; } +/** The colour the swatch row is showing and setting, or `null` when no tool has one. */ +export function activeColor(state: Pick): string | null { + const tool = state.activeTool; + if (tool === 'note') return state.noteColor; + return isInkTool(tool) ? state.settings[tool].color : null; +} + /** The stroke style the ink engine should use right now, or `null` in reading mode. */ export function currentStrokeStyle(state: PaletteState) { const tool = state.activeTool; diff --git a/src/ui/Ink/DisplacedTray.tsx b/src/ui/Ink/DisplacedTray.tsx index b3a5fbb..ded8f58 100644 --- a/src/ui/Ink/DisplacedTray.tsx +++ b/src/ui/Ink/DisplacedTray.tsx @@ -14,8 +14,12 @@ 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] ?? ''; } @@ -49,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 110d59f..3f27906 100644 --- a/src/ui/Ink/InkSurface.tsx +++ b/src/ui/Ink/InkSurface.tsx @@ -31,6 +31,7 @@ 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 { @@ -338,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. @@ -425,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/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} +
    + +