diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9fd632..6d3c71b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,10 +134,12 @@ jobs: cd "$TAP_DIR" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - if git diff --quiet -- Casks/mnemo.rb; then + git add Casks/mnemo.rb + # Stage first, then check the staged diff: a brand-new (untracked) cask + # file is invisible to `git diff` but shows up in `git diff --cached`. + if git diff --cached --quiet; then echo "Cask already up to date for ${VERSION}" exit 0 fi - git add Casks/mnemo.rb git commit -m "mnemo ${TAG}" git push diff --git a/docs/superpowers/plans/2026-06-30-cut-split-tool.md b/docs/superpowers/plans/2026-06-30-cut-split-tool.md new file mode 100644 index 0000000..68b3223 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-cut-split-tool.md @@ -0,0 +1,995 @@ +# Cut / Split Tool Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a scissors tool to the markdown toolbar that snaps a guide line to block boundaries and offers Delete above / Delete below / Split at that point. + +**Architecture:** Extend the existing `rehypeSourcePositions` plugin to tag block elements with their source offset; a DOM helper turns those into snap boundaries; pure functions slice `content_md`; `splitChat` creates a second note from the part below. The toolbar gains a mutually-exclusive tool mode (`none`/`highlight`/`cut`). + +**Tech Stack:** React 19 + TypeScript, react-markdown@10 (remark-gfm, rehype-raw), Bun (`bun test`), Tauri, SQLite via `@tauri-apps/plugin-sql`. + +## Global Constraints + +- Block annotation attribute is exactly `data-md-block-start` (read via `el.dataset.mdBlockStart`); annotated block tags: `p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, table, hr`. +- Cut units (snap points) are annotated blocks whose parent is the content container (top-level) OR a `UL`/`OL` (list items). The guide line appears only in gaps *between* consecutive cut units. +- Boundary offset = the `data-md-block-start` of the block **below** the gap. Delete above = keep from offset; Delete below = keep up to offset; Split = above stays, below → new note. +- Split-off note inherits source + folder + tags; title = first markdown heading in the part below, else `" (2)"`; no AI re-analysis; user stays on the current note. +- Delete shows a confirmation, then an Undo affordance for ~6s restoring the previous `content_md`. +- Tool mode is mutually exclusive (`type ToolMode = "none" | "highlight" | "cut"`); `Esc` resets to `"none"`; switching chats resets to `"none"`. +- `src/lib/highlight.ts` and `src/lib/cut.ts` must stay DOM-free (importable by `bun test`); DOM code lives in `src/lib/cut-dom.ts`. +- Verify TypeScript with `bun run build` (NOT `npx tsc --noEmit`, which is a no-op here — `tsconfig.json` is a references stub). `*.test.ts` are excluded from the app build and run via `bun test`. +- Commit messages: concise, no `Co-Authored-By` line. + +--- + +### Task 1: Annotate block elements with source offsets (+ de-risk) + +**Files:** +- Modify: `src/lib/highlight.ts` (the `HastElement` type and `walkHast`, ~lines 113-157) +- Test: `src/lib/cut-plugin.test.ts` + +**Interfaces:** +- Consumes: existing `rehypeSourcePositions` pipeline. +- Produces: block elements (`p,h1-6,li,blockquote,pre,table,hr`) carry `data-md-block-start="<sourceOffset>"`. + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/cut-plugin.test.ts`: +```ts +import { test, expect } from "bun:test"; +import { unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkGfm from "remark-gfm"; +import remarkRehype from "remark-rehype"; +import rehypeRaw from "rehype-raw"; +import { rehypeSourcePositions } from "./highlight"; + +function toHast(md: string) { + const p = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw); + return p.runSync(p.parse(md)) as any; +} + +function collect(node: any, acc: any[] = []): any[] { + if (node.type === "element" && node.properties?.["data-md-block-start"] != null) acc.push(node); + for (const c of node.children ?? []) collect(c, acc); + return acc; +} + +test("block elements get data-md-block-start pointing at the block's source start", () => { + const md = "## Heading\n\nA paragraph.\n\n- first item\n- second item\n"; + const hast = toHast(md); + rehypeSourcePositions()(hast); + const blocks = collect(hast); + // tag -> source offset; verify the source at that offset begins the block. + const byTag: Record<string, number> = {}; + for (const b of blocks) byTag[b.tagName] ??= Number(b.properties["data-md-block-start"]); + + expect(md.startsWith("## Heading", byTag["h2"])).toBe(true); + expect(md.startsWith("A paragraph.", byTag["p"])).toBe(true); + // first li starts at the "- first item" marker + const liStart = Number(blocks.find((b) => b.tagName === "li").properties["data-md-block-start"]); + expect(md.startsWith("- first item", liStart)).toBe(true); +}); + +test("each list item gets its own block-start at its marker", () => { + const md = "- alpha\n- beta\n- gamma\n"; + const hast = toHast(md); + rehypeSourcePositions()(hast); + const lis = collect(hast).filter((b) => b.tagName === "li"); + expect(lis.length).toBe(3); + const starts = lis.map((b) => Number(b.properties["data-md-block-start"])).sort((a, b) => a - b); + expect(md.startsWith("- alpha", starts[0])).toBe(true); + expect(md.startsWith("- beta", starts[1])).toBe(true); + expect(md.startsWith("- gamma", starts[2])).toBe(true); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test src/lib/cut-plugin.test.ts` +Expected: FAIL (no `data-md-block-start` attributes yet). If instead it errors that block elements have no `position`, STOP — the de-risk failed and the approach needs revisiting; report back. + +- [ ] **Step 3: Add `position` to the `HastElement` type** + +In `src/lib/highlight.ts`, change the `HastElement` interface to include an optional position: +```ts +interface HastElement { + type: "element"; + tagName: string; + properties?: Record<string, unknown>; + children: HastChild[]; + position?: { start?: { offset?: number } }; +} +``` + +- [ ] **Step 4: Annotate block elements in `walkHast`** + +In `src/lib/highlight.ts`, add the block-tag set next to `SKIP_TAGS`: +```ts +const BLOCK_TAGS = new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "pre", "table", "hr"]); +``` +Then, inside `walkHast`, in the `if (child.type === "element")` branch, annotate before recursing: +```ts + if (child.type === "element") { + const el = child as HastElement; + if (BLOCK_TAGS.has(el.tagName)) { + const blockStart = el.position?.start?.offset; + if (blockStart != null) { + el.properties = el.properties ?? {}; + el.properties["data-md-block-start"] = String(blockStart); + } + } + walkHast(el, inSkip || SKIP_TAGS.has(el.tagName)); + } else if (child.type === "text" && !inSkip) { +``` +(The text-node branch below is unchanged.) + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `bun test src/lib/cut-plugin.test.ts` +Expected: PASS (2 tests). Then run the whole suite: `bun test` → all green (existing highlight tests unaffected). + +- [ ] **Step 6: Build** + +Run: `bun run build` +Expected: `✓ built` with no errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/highlight.ts src/lib/cut-plugin.test.ts +git commit -m "feat: annotate block elements with source offsets for the cut tool" +``` + +--- + +### Task 2: Pure markdown cut operations (`cut.ts`) + +**Files:** +- Create: `src/lib/cut.ts` +- Test: `src/lib/cut.test.ts` + +**Interfaces:** +- Produces (used by Tasks 4, 7): + - `deleteAbove(md: string, offset: number): string` + - `deleteBelow(md: string, offset: number): string` + - `splitMarkdown(md: string, offset: number): { above: string; below: string }` + - `deriveSplitTitle(belowMd: string, originalTitle: string): string` + +- [ ] **Step 1: Write the failing tests** + +Create `src/lib/cut.test.ts`: +```ts +import { test, expect } from "bun:test"; +import { deleteAbove, deleteBelow, splitMarkdown, deriveSplitTitle } from "./cut"; + +const md = "# A\n\nbody text\n\n## B\n\nmore text\n"; +const at = md.indexOf("## B"); + +test("deleteAbove keeps from the offset, trimming leading whitespace", () => { + expect(deleteAbove(md, at)).toBe("## B\n\nmore text"); +}); + +test("deleteBelow keeps up to the offset, trimming trailing whitespace", () => { + expect(deleteBelow(md, at)).toBe("# A\n\nbody text"); +}); + +test("splitMarkdown returns trimmed above/below halves", () => { + expect(splitMarkdown(md, at)).toEqual({ above: "# A\n\nbody text", below: "## B\n\nmore text" }); +}); + +test("deriveSplitTitle uses the first heading in the part below", () => { + expect(deriveSplitTitle("## Section two\n\ntext", "Orig")).toBe("Section two"); +}); + +test("deriveSplitTitle finds a heading even if not on the first line", () => { + expect(deriveSplitTitle("intro line\n\n### Deep\n\nx", "Orig")).toBe("Deep"); +}); + +test("deriveSplitTitle falls back to '<title> (2)' when there is no heading", () => { + expect(deriveSplitTitle("just text, no heading", "Orig")).toBe("Orig (2)"); +}); + +test("deriveSplitTitle strips trailing closing hashes", () => { + expect(deriveSplitTitle("# Title #\n", "Orig")).toBe("Title"); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `bun test src/lib/cut.test.ts` +Expected: FAIL (module `./cut` not found). + +- [ ] **Step 3: Implement `cut.ts`** + +Create `src/lib/cut.ts`: +```ts +export function deleteAbove(md: string, offset: number): string { + return md.slice(offset).replace(/^\s+/, ""); +} + +export function deleteBelow(md: string, offset: number): string { + return md.slice(0, offset).replace(/\s+$/, ""); +} + +export function splitMarkdown(md: string, offset: number): { above: string; below: string } { + return { + above: md.slice(0, offset).replace(/\s+$/, ""), + below: md.slice(offset).replace(/^\s+/, ""), + }; +} + +export function deriveSplitTitle(belowMd: string, originalTitle: string): string { + const m = belowMd.match(/^#{1,6}\s+(.+?)\s*#*\s*$/m); + if (m) return m[1].trim(); + return `${originalTitle} (2)`; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `bun test src/lib/cut.test.ts` +Expected: PASS (7 tests). + +- [ ] **Step 5: Build** + +Run: `bun run build` +Expected: `✓ built`. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/cut.ts src/lib/cut.test.ts +git commit -m "feat: pure markdown cut/split operations" +``` + +--- + +### Task 3: Cut boundaries from the DOM (`cut-dom.ts`) + +**Files:** +- Create: `src/lib/cut-dom.ts` +- Test: `src/lib/cut-dom.test.ts` + +**Interfaces:** +- Produces (used by Task 6): + - `interface CutBoundary { y: number; offset: number }` + - `getCutBoundaries(container: HTMLElement): CutBoundary[]` + - `nearestBoundary(boundaries: CutBoundary[], y: number): CutBoundary | null` + +- [ ] **Step 1: Write the failing test (pure part)** + +Create `src/lib/cut-dom.test.ts`: +```ts +import { test, expect } from "bun:test"; +import { nearestBoundary, type CutBoundary } from "./cut-dom"; + +const bs: CutBoundary[] = [ + { y: 10, offset: 5 }, + { y: 50, offset: 20 }, + { y: 200, offset: 99 }, +]; + +test("nearestBoundary returns the closest boundary by y", () => { + expect(nearestBoundary(bs, 12)).toEqual({ y: 10, offset: 5 }); + expect(nearestBoundary(bs, 40)).toEqual({ y: 50, offset: 20 }); + expect(nearestBoundary(bs, 1000)).toEqual({ y: 200, offset: 99 }); +}); + +test("nearestBoundary returns null for an empty list", () => { + expect(nearestBoundary([], 5)).toBeNull(); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test src/lib/cut-dom.test.ts` +Expected: FAIL (module `./cut-dom` not found). + +- [ ] **Step 3: Implement `cut-dom.ts`** + +Create `src/lib/cut-dom.ts`: +```ts +export interface CutBoundary { + y: number; // position in the container's scroll-content coordinate space + offset: number; // content_md offset of the block below the gap +} + +// Pure: the boundary whose y is closest to the target y. +export function nearestBoundary(boundaries: CutBoundary[], y: number): CutBoundary | null { + let best: CutBoundary | null = null; + let bestDist = Infinity; + for (const b of boundaries) { + const d = Math.abs(b.y - y); + if (d < bestDist) { + bestDist = d; + best = b; + } + } + return best; +} + +// DOM: gaps between consecutive cut units (top-level blocks and list items). +export function getCutBoundaries(container: HTMLElement): CutBoundary[] { + const isCutUnit = (el: HTMLElement): boolean => { + const p = el.parentElement; + return p === container || (!!p && (p.tagName === "UL" || p.tagName === "OL")); + }; + const containerTop = container.getBoundingClientRect().top; + const scrollTop = container.scrollTop; + const items = Array.from(container.querySelectorAll<HTMLElement>("[data-md-block-start]")) + .filter(isCutUnit) + .map((el) => { + const r = el.getBoundingClientRect(); + return { + top: r.top - containerTop + scrollTop, + bottom: r.bottom - containerTop + scrollTop, + offset: Number(el.dataset.mdBlockStart), + }; + }) + .filter((it) => !Number.isNaN(it.offset)) + .sort((a, b) => a.top - b.top); + + const boundaries: CutBoundary[] = []; + for (let i = 0; i < items.length - 1; i++) { + boundaries.push({ y: (items[i].bottom + items[i + 1].top) / 2, offset: items[i + 1].offset }); + } + return boundaries; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `bun test src/lib/cut-dom.test.ts` +Expected: PASS (2 tests). `getCutBoundaries` is DOM-dependent and is verified manually in Task 6. + +- [ ] **Step 5: Build** + +Run: `bun run build` +Expected: `✓ built`. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/cut-dom.ts src/lib/cut-dom.test.ts +git commit -m "feat: compute cut boundaries from rendered blocks" +``` + +--- + +### Task 4: `splitChat` in useDatabase + thread to ChatDetail + +**Files:** +- Modify: `src/hooks/useDatabase.ts` (add `splitChat`, export it in the return object ~line 432) +- Modify: `src/App.tsx` (destructure `splitChat` ~line 59; pass `onSplitChat` and `onOpenChat` to `<ChatDetail>` ~line 482) +- Modify: `src/components/ChatDetail/ChatDetail.tsx` (extend `ChatDetailProps`) + +**Interfaces:** +- Consumes: `splitMarkdown`, `deriveSplitTitle` from `../lib/cut`; `db.insertChat`, `db.getTagsForChat`, `db.addTagToChat`, `db.updateChat`, `db.getAllChats`. +- Produces: `splitChat(chatId: string, offset: number): Promise<Chat | null>`; `ChatDetailProps.onSplitChat` and `ChatDetailProps.onOpenChat`. + +- [ ] **Step 1: Import the pure ops in useDatabase** + +In `src/hooks/useDatabase.ts`, add near the other imports: +```ts +import { splitMarkdown, deriveSplitTitle } from "../lib/cut"; +``` + +- [ ] **Step 2: Implement `splitChat`** + +In `src/hooks/useDatabase.ts`, add this callback alongside the other `useCallback`s (e.g., just after `importFile`): +```ts + const splitChat = useCallback(async (chatId: string, offset: number): Promise<Chat | null> => { + const all = await db.getAllChats(); + const chat = all.find((c) => c.id === chatId); + if (!chat) return null; + const { above, below } = splitMarkdown(chat.content_md, offset); + const newChat = await db.insertChat({ + title: deriveSplitTitle(below, chat.title), + summary: null, + source: chat.source, + content_md: below, + content_html: null, + imported_at: new Date().toISOString(), + chat_date: chat.chat_date, + folder_id: chat.folder_id, + deleted_at: null, + favorite: 0, + }); + const tags = await db.getTagsForChat(chatId); + for (const t of tags) await db.addTagToChat(newChat.id, t.id); + await db.updateChat(chatId, { content_md: above }); + await refreshChats(); + await refreshTags(); + await refreshFolders(); + const updatedCurrent = (await db.getAllChats()).find((c) => c.id === chatId); + if (updatedCurrent) setSelectedChat(updatedCurrent); + return newChat; + }, [refreshChats, refreshTags, refreshFolders, setSelectedChat]); +``` + +- [ ] **Step 3: Export `splitChat` from the hook** + +In the `return { ... }` object of `useDatabase` (~line 432), add `splitChat,` (e.g., right after `importFile,`). + +- [ ] **Step 4: Extend `ChatDetailProps`** + +In `src/components/ChatDetail/ChatDetail.tsx`, add to the `ChatDetailProps` interface: +```ts + onSplitChat: (chatId: string, offset: number) => Promise<Chat | null>; + onOpenChat: (chatId: string) => void; +``` +(`Chat` is already imported at the top of the file.) + +- [ ] **Step 5: Wire props in App** + +In `src/App.tsx`, add `splitChat` to the `useDatabase()` destructure (~line 59, near `importFile, updateChat`). Then in the `<ChatDetail ... />` JSX (~line 482), add: +```tsx + onSplitChat={splitChat} + onOpenChat={(id) => { + const c = chats.find((x) => x.id === id); + if (c) setSelectedChat(c); + }} +``` +(`chats` and `setSelectedChat` are already destructured from `useDatabase()`.) + +- [ ] **Step 6: Build** + +Run: `bun run build` +Expected: `✓ built` (props are now required; the single `<ChatDetail>` call site supplies them). Also run `bun test` → still green. + +- [ ] **Step 7: Commit** + +```bash +git add src/hooks/useDatabase.ts src/App.tsx src/components/ChatDetail/ChatDetail.tsx +git commit -m "feat: splitChat creates a second note from the part below" +``` + +--- + +### Task 5: Tool mode refactor + scissors button + +**Files:** +- Modify: `src/components/ChatDetail/MarkdownToolbar.tsx` (two tools) +- Modify: `src/components/ChatDetail/ChatDetail.tsx` (`armed` → `tool` mode) +- Modify: `src/index.css` (crosshair for cut mode) + +**Interfaces:** +- Produces: `export type ToolMode = "none" | "highlight" | "cut"` from `MarkdownToolbar`; `ChatDetail` holds `tool` state used by Task 6. + +- [ ] **Step 1: Rewrite `MarkdownToolbar` with two tools** + +Replace the whole of `src/components/ChatDetail/MarkdownToolbar.tsx` with: +```tsx +export type ToolMode = "none" | "highlight" | "cut"; + +interface MarkdownToolbarProps { + tool: ToolMode; + notice: string | null; + onToggleHighlight: () => void; + onToggleCut: () => void; +} + +export default function MarkdownToolbar({ tool, notice, onToggleHighlight, onToggleCut }: MarkdownToolbarProps) { + return ( + <div className="md-toolbar"> + <button + type="button" + className={`md-toolbar-btn${tool === "highlight" ? " armed" : ""}`} + title={tool === "highlight" ? "Highlighter on — drag over text to highlight (Esc to stop)" : "Highlighter — click, then drag over the text"} + aria-pressed={tool === "highlight"} + onClick={onToggleHighlight} + > + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="m9 11-6 6v3h9l3-3" /> + <path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" /> + </svg> + </button> + <button + type="button" + className={`md-toolbar-btn${tool === "cut" ? " armed" : ""}`} + title={tool === "cut" ? "Cut on — click between blocks to delete or split (Esc to stop)" : "Cut — click, then pick a spot between blocks"} + aria-pressed={tool === "cut"} + onClick={onToggleCut} + > + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="6" cy="6" r="3" /> + <circle cx="6" cy="18" r="3" /> + <line x1="20" y1="4" x2="8.12" y2="15.88" /> + <line x1="14.47" y1="14.48" x2="20" y2="20" /> + <line x1="8.12" y1="8.12" x2="12" y2="12" /> + </svg> + </button> + {tool === "highlight" && <span className="md-toolbar-status">Drag to highlight · Esc to stop</span>} + {tool === "cut" && <span className="md-toolbar-status">Click between blocks · Esc to stop</span>} + {notice && <span className="md-toolbar-notice">{notice}</span>} + </div> + ); +} +``` + +- [ ] **Step 2: Refactor `ChatDetail` state from `armed` to `tool`** + +In `src/components/ChatDetail/ChatDetail.tsx`: + +(a) Update the toolbar import to also pull the type: +```tsx +import MarkdownToolbar, { type ToolMode } from "./MarkdownToolbar"; +``` + +(b) Replace the state declaration `const [armed, setArmed] = useState(false);` with: +```tsx + const [tool, setTool] = useState<ToolMode>("none"); +``` + +(c) In the `[chat.id]` reset effect, replace `setArmed(false);` with: +```tsx + setTool("none"); +``` + +(d) Replace `handleToggleHighlighter` with two togglers: +```tsx + const handleToggleHighlight = useCallback(() => { + setTool((t) => (t === "highlight" ? "none" : "highlight")); + }, []); + const handleToggleCut = useCallback(() => { + setTool((t) => (t === "cut" ? "none" : "cut")); + }, []); +``` + +(e) In the highlight mouseup effect, change the guard `if (!armed) return;` to `if (tool !== "highlight") return;`, change the apply call's color arg to keep `"yellow"`, and update its dependency array from `[armed, ...]` to `[tool, chat.id, chat.content_md, onUpdateChat, focusMode]`. + +(f) Replace the Esc effect with one that exits any tool: +```tsx + useEffect(() => { + if (tool === "none") return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setTool("none"); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [tool]); +``` + +(g) In BOTH content-container `className` template literals, replace `${armed ? " hl-armed" : ""}` with: +```tsx +${tool === "highlight" ? " hl-armed" : tool === "cut" ? " cut-armed" : ""} +``` + +(h) In BOTH `<MarkdownToolbar ... />` usages, replace the props `armed={armed} ... onToggle={handleToggleHighlighter}` with: +```tsx + tool={tool} + notice={hlNotice} + onToggleHighlight={handleToggleHighlight} + onToggleCut={handleToggleCut} +``` + +- [ ] **Step 3: Add crosshair CSS for cut mode** + +In `src/index.css`, just after the existing `.md-content.hl-armed` rule, add: +```css +.md-content.cut-armed, +.md-content.cut-armed * { + cursor: crosshair; +} +``` + +- [ ] **Step 4: Build and test** + +Run: `bun run build` → `✓ built`; `bun test` → all green. + +- [ ] **Step 5: Manual verification** + +`bun run tauri dev`: the highlighter still works exactly as before; a second (scissors) button appears; clicking it shows a crosshair over the content and the "Click between blocks · Esc to stop" hint; the two tools are mutually exclusive; `Esc` exits. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/ChatDetail/MarkdownToolbar.tsx src/components/ChatDetail/ChatDetail.tsx src/index.css +git commit -m "refactor: mutually-exclusive tool mode + scissors button" +``` + +--- + +### Task 6: CutOverlay — guide line + action menu + +**Files:** +- Create: `src/components/ChatDetail/CutOverlay.tsx` +- Modify: `src/components/ChatDetail/ChatDetail.tsx` (render `CutOverlay` when `tool === "cut"`, with stub handlers) +- Modify: `src/index.css` (guide line + menu) + +**Interfaces:** +- Consumes: `getCutBoundaries`, `nearestBoundary`, `type CutBoundary` from `../../lib/cut-dom`. +- Produces: `CutOverlay` calling `onDeleteAbove(offset)`, `onDeleteBelow(offset)`, `onSplit(offset)`. + +- [ ] **Step 1: Create the `CutOverlay` component** + +Create `src/components/ChatDetail/CutOverlay.tsx`: +```tsx +import { useEffect, useRef, useState, useCallback } from "react"; +import { getCutBoundaries, nearestBoundary, type CutBoundary } from "../../lib/cut-dom"; + +interface CutOverlayProps { + containerRef: React.RefObject<HTMLDivElement | null>; + contentMd: string; + onDeleteAbove: (offset: number) => void; + onDeleteBelow: (offset: number) => void; + onSplit: (offset: number) => void; +} + +export default function CutOverlay({ containerRef, contentMd, onDeleteAbove, onDeleteBelow, onSplit }: CutOverlayProps) { + const boundaries = useRef<CutBoundary[]>([]); + const [lineY, setLineY] = useState<number | null>(null); + const [menu, setMenu] = useState<{ x: number; y: number; offset: number } | null>(null); + const current = useRef<CutBoundary | null>(null); + + const recompute = useCallback(() => { + const c = containerRef.current; + if (c) boundaries.current = getCutBoundaries(c); + }, [containerRef]); + + // Recompute boundaries when the tool opens, the content changes, or layout shifts. + useEffect(() => { + recompute(); + const c = containerRef.current; + if (!c) return; + c.addEventListener("scroll", recompute); + window.addEventListener("resize", recompute); + return () => { + c.removeEventListener("scroll", recompute); + window.removeEventListener("resize", recompute); + }; + }, [recompute, contentMd]); + + useEffect(() => { + const c = containerRef.current; + if (!c) return; + const onMove = (e: MouseEvent) => { + if (menu) return; // freeze the line while the menu is open + const rect = c.getBoundingClientRect(); + const yInContent = e.clientY - rect.top + c.scrollTop; + const b = nearestBoundary(boundaries.current, yInContent); + current.current = b; + setLineY(b ? b.y : null); + }; + const onClick = (e: MouseEvent) => { + if (!current.current) return; + e.preventDefault(); + setMenu({ x: e.clientX, y: e.clientY, offset: current.current.offset }); + }; + c.addEventListener("mousemove", onMove); + c.addEventListener("click", onClick); + return () => { + c.removeEventListener("mousemove", onMove); + c.removeEventListener("click", onClick); + }; + }, [containerRef, menu]); + + // Close the menu on Escape or outside click. + useEffect(() => { + if (!menu) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenu(null); }; + const onDown = (e: MouseEvent) => { + if (!(e.target as HTMLElement)?.closest(".cut-menu")) setMenu(null); + }; + window.addEventListener("keydown", onKey, true); + window.addEventListener("mousedown", onDown, true); + return () => { + window.removeEventListener("keydown", onKey, true); + window.removeEventListener("mousedown", onDown, true); + }; + }, [menu]); + + const act = (fn: (offset: number) => void) => { + if (menu) fn(menu.offset); + setMenu(null); + }; + + return ( + <> + {lineY != null && <div className="cut-line" style={{ top: lineY }} />} + {menu && ( + <div className="cut-menu" style={{ left: menu.x, top: menu.y }}> + <button type="button" onClick={() => act(onDeleteAbove)}>Delete above</button> + <button type="button" onClick={() => act(onDeleteBelow)}>Delete below</button> + <button type="button" onClick={() => act(onSplit)}>Split</button> + </div> + )} + </> + ); +} +``` + +- [ ] **Step 2: Render `CutOverlay` from ChatDetail with stub handlers** + +In `src/components/ChatDetail/ChatDetail.tsx`, add the import: +```tsx +import CutOverlay from "./CutOverlay"; +``` +Then, in BOTH content containers (focus and normal), immediately after the `<MarkdownToolbar .../>` element, add: +```tsx + {tool === "cut" && ( + <CutOverlay + containerRef={contentRef} + contentMd={chat.content_md} + onDeleteAbove={(offset) => console.warn("deleteAbove", offset)} + onDeleteBelow={(offset) => console.warn("deleteBelow", offset)} + onSplit={(offset) => console.warn("split", offset)} + /> + )} +``` +(These stubs are replaced in Task 7.) + +- [ ] **Step 3: Make the content container a positioning context + style the line and menu** + +In `src/index.css`, add: +```css +/* Cut tool: the content container anchors the absolutely-positioned guide line */ +.md-content.cut-armed { + position: relative; + user-select: none; +} +.cut-line { + position: absolute; + left: 0; + right: 0; + height: 0; + border-top: 2px dashed var(--red); + pointer-events: none; + z-index: 6; +} +.cut-menu { + position: fixed; + z-index: 50; + display: flex; + flex-direction: column; + min-width: 140px; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 4px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); +} +.cut-menu button { + text-align: left; + background: none; + border: none; + color: var(--text-primary); + padding: 7px 10px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; +} +.cut-menu button:hover { + background: var(--bg-hover); +} +``` + +- [ ] **Step 4: Build and test** + +Run: `bun run build` → `✓ built`; `bun test` → green. + +- [ ] **Step 5: Manual verification** + +`bun run tauri dev`: arm the scissors; moving the mouse over the content shows a red dashed line snapping to the gaps between paragraphs and between list items (and around a code block); clicking opens the 3-item menu at the cursor; choosing an item logs `deleteAbove/deleteBelow/split` with an offset to the console; `Esc`/outside-click closes the menu. (The actions are wired in Task 7.) + +- [ ] **Step 6: Commit** + +```bash +git add src/components/ChatDetail/CutOverlay.tsx src/components/ChatDetail/ChatDetail.tsx src/index.css +git commit -m "feat: cut guide line and action menu overlay" +``` + +--- + +### Task 7: Wire cut actions — delete (confirm + undo) and split (toast) + +**Files:** +- Modify: `src/components/ChatDetail/ChatDetail.tsx` (real handlers, confirm modal, undo toast, split toast) +- Modify: `src/index.css` (confirm modal reuse + toast) + +**Interfaces:** +- Consumes: `deleteAbove`, `deleteBelow` from `../../lib/cut`; `onSplitChat`, `onOpenChat` (Task 4); `onUpdateChat`. + +- [ ] **Step 1: Import the pure delete ops** + +In `src/components/ChatDetail/ChatDetail.tsx`, add to the `../../lib/cut` usage (new import line): +```tsx +import { deleteAbove, deleteBelow } from "../../lib/cut"; +``` + +- [ ] **Step 2: Add cut-action state** + +Near the other `useState`s in `ChatDetail`, add: +```tsx + const [cutConfirm, setCutConfirm] = useState<{ direction: "above" | "below"; offset: number } | null>(null); + const [cutUndo, setCutUndo] = useState<string | null>(null); // previous content_md + const [splitToast, setSplitToast] = useState<string | null>(null); // new chat id + const cutUndoTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + const splitToastTimer = useRef<ReturnType<typeof setTimeout> | null>(null); +``` +In the `[chat.id]` reset effect, also add: +```tsx + setCutConfirm(null); + setCutUndo(null); + setSplitToast(null); + if (cutUndoTimer.current) { clearTimeout(cutUndoTimer.current); cutUndoTimer.current = null; } + if (splitToastTimer.current) { clearTimeout(splitToastTimer.current); splitToastTimer.current = null; } +``` + +- [ ] **Step 3: Add the cut handlers** + +In `ChatDetail`, near the other cut-related callbacks, add: +```tsx + const performDelete = useCallback((direction: "above" | "below", offset: number) => { + const prev = chat.content_md; + const next = direction === "above" ? deleteAbove(prev, offset) : deleteBelow(prev, offset); + onUpdateChat(chat.id, { content_md: next }); + setCutUndo(prev); + if (cutUndoTimer.current) clearTimeout(cutUndoTimer.current); + cutUndoTimer.current = setTimeout(() => setCutUndo(null), 6000); + }, [chat.id, chat.content_md, onUpdateChat]); + + const handleCutUndo = useCallback(() => { + if (cutUndo == null) return; + onUpdateChat(chat.id, { content_md: cutUndo }); + setCutUndo(null); + if (cutUndoTimer.current) { clearTimeout(cutUndoTimer.current); cutUndoTimer.current = null; } + }, [cutUndo, chat.id, onUpdateChat]); + + const handleSplit = useCallback(async (offset: number) => { + const newChat = await onSplitChat(chat.id, offset); + if (newChat) { + setSplitToast(newChat.id); + if (splitToastTimer.current) clearTimeout(splitToastTimer.current); + splitToastTimer.current = setTimeout(() => setSplitToast(null), 8000); + } + }, [chat.id, onSplitChat]); +``` + +- [ ] **Step 4: Connect the overlay handlers** + +In BOTH `<CutOverlay ... />` usages, replace the three stub props with: +```tsx + onDeleteAbove={(offset) => setCutConfirm({ direction: "above", offset })} + onDeleteBelow={(offset) => setCutConfirm({ direction: "below", offset })} + onSplit={(offset) => handleSplit(offset)} +``` + +- [ ] **Step 5: Render confirm modal + toasts (shared by both views)** + +The scissors tool is available in both the focus and normal views, so the modals must render in both. To avoid duplicating JSX, define them once as a const BEFORE the `if (focusMode) { return ... }` early return in `ChatDetail`: +```tsx + const cutModals = ( + <> + {cutConfirm && ( + <div className="expand-modal-overlay" onClick={() => setCutConfirm(null)}> + <div className="cut-confirm" onClick={(e) => e.stopPropagation()}> + <div className="cut-confirm-text"> + Delete the part {cutConfirm.direction} this line? + </div> + <div className="cut-confirm-actions"> + <button className="cut-confirm-cancel" onClick={() => setCutConfirm(null)}>Cancel</button> + <button className="cut-confirm-delete" onClick={() => { performDelete(cutConfirm.direction, cutConfirm.offset); setCutConfirm(null); }}>Delete</button> + </div> + </div> + </div> + )} + {cutUndo != null && ( + <div className="cut-toast"> + <span>Content deleted</span> + <button onClick={handleCutUndo}>Undo</button> + </div> + )} + {splitToast && ( + <div className="cut-toast"> + <span>Note split</span> + <button onClick={() => { onOpenChat(splitToast); setSplitToast(null); }}>Open new note</button> + </div> + )} + </> + ); +``` +Then include `{cutModals}` once in the focus return (just before its root `</div>` closes) and once in the normal return (next to the other modals like `mdPreview`). Two insertion points, the same `{cutModals}` expression in each — do not re-inline the JSX. + +- [ ] **Step 6: Style the confirm modal and toast** + +In `src/index.css`, add: +```css +.cut-confirm { + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: 10px; + padding: 18px; + max-width: 320px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); +} +.cut-confirm-text { + color: var(--text-primary); + font-size: 14px; + margin-bottom: 14px; +} +.cut-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} +.cut-confirm-actions button { + border-radius: 6px; + padding: 6px 12px; + font-size: 13px; + cursor: pointer; + border: 1px solid var(--border-subtle); +} +.cut-confirm-cancel { background: none; color: var(--text-muted); } +.cut-confirm-delete { background: var(--red); border-color: var(--red); color: #fff; } +.cut-toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 60; + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 10px 14px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + font-size: 13px; + color: var(--text-primary); +} +.cut-toast button { + background: none; + border: none; + color: var(--accent, #2d6eb4); + font-weight: 600; + cursor: pointer; +} +``` + +- [ ] **Step 7: Build and test** + +Run: `bun run build` → `✓ built`; `bun test` → green. + +- [ ] **Step 8: Manual verification** + +`bun run tauri dev`: +1. Scissors → click between two blocks → menu → Delete above → confirm → top gone → Undo toast restores it within 6s. +2. Delete below similarly. +3. Split between two blocks → current note keeps the top; a new note exists with the bottom, same source/folder/tags, title from the bottom's first heading (or "<title> (2)"); "Open new note" toast switches to it. +4. Cutting between two list items splits the list cleanly; cutting around a code block works. + +- [ ] **Step 9: Commit** + +```bash +git add src/components/ChatDetail/ChatDetail.tsx src/index.css +git commit -m "feat: cut actions — delete with confirm+undo, split with toast" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Scissors tool + crosshair → Tasks 5, 6. +- Guide line snapping between blocks incl. list items → Tasks 1, 3, 6. +- Block source offsets via the plugin → Task 1. +- Boundary offset = below block's start; delete above/below/split slicing → Tasks 2, 7. +- Split inherits source+folder+tags, derived title, no AI, stay on current + toast → Tasks 2, 4, 7. +- Delete confirm + undo (~6s) → Task 7. +- Mutually-exclusive tool mode + Esc + reset on chat change → Task 5. +- De-risk block offsets first → Task 1 (Step 2 gate). +- DOM-free `cut.ts`/`highlight.ts`, DOM in `cut-dom.ts` → Tasks 1-3. +- Re-index on edit handled by existing `insertChat`/`updateChat` → Task 4 (no extra work). +- Verify with `bun run build` not `npx tsc --noEmit` → Global Constraints, every build step. + +**Placeholder scan:** None — every code step contains full code; manual-verification steps describe concrete observable outcomes. + +**Type consistency:** `CutBoundary`, `getCutBoundaries`, `nearestBoundary`, `deleteAbove`, `deleteBelow`, `splitMarkdown`, `deriveSplitTitle`, `splitChat`, `ToolMode`, `onSplitChat`, `onOpenChat`, and `data-md-block-start` are used with identical names/signatures across tasks. + +## Notes / Known limitations (from the spec) + +- No sub-block precision (can't cut inside a paragraph or code block). +- Cutting inside a multi-paragraph blockquote is possible but rare; not specially handled. +- Undo holds the previous `content_md` only until the 6s timer or a chat change. +- Split has no undo (non-destructive; merge back manually). diff --git a/docs/superpowers/specs/2026-06-30-cut-split-tool-design.md b/docs/superpowers/specs/2026-06-30-cut-split-tool-design.md new file mode 100644 index 0000000..dd9f6e9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-cut-split-tool-design.md @@ -0,0 +1,157 @@ +# Cut / Split Tool — Design + +- **Date:** 2026-06-30 +- **Status:** Approved for spec review +- **Component area:** `src/components/ChatDetail/`, `src/lib/`, `src/hooks/useDatabase.ts`, `src/index.css` + +## Summary + +Add a second tool to the markdown toolbar: a **scissors (cut) tool**. When active, the +cursor is a crosshair and a horizontal guide line snaps to the gap between blocks +nearest the mouse. Left-clicking the line opens a small menu with three actions: + +1. **Delete above** — remove all content above the line. +2. **Delete below** — remove all content below the line. +3. **Split** — split the note in two at the line: the current note keeps the part + above, a new note is created with the part below. + +The cut point maps to a precise offset in `content_md` via source positions exposed on +block elements, so every action edits the markdown source cleanly at a block boundary. + +## User decisions (from brainstorming) + +1. **Granularity:** the line snaps to boundaries between blocks **including individual + list items** (`li`), not only top-level blocks. The line only appears in gaps + *between* consecutive blocks (never above the first or below the last), so both + sides always have content. +2. **Split inheritance:** the new note inherits **source + folder + tags**. Its title is + the first heading found in the part below, falling back to `"<original title> (2)"`. + **No AI re-analysis.** After splitting, the user stays on the current (top) note; a + toast links to the new note. +3. **Delete safety:** **both** a confirmation prompt **and** an undo. Delete asks to + confirm, performs the edit, then shows an "Undo" affordance for ~6 seconds that + restores the previous `content_md`. + +## Architecture + +### Tool mode (replaces the highlighter's `armed` boolean) + +`ChatDetail` currently tracks `armed: boolean` for the highlighter. Replace it with a +single mutually-exclusive mode: + +```ts +type ToolMode = "none" | "highlight" | "cut"; +``` + +Only one tool is active at a time; activating the scissors turns the highlighter off and +vice-versa. `Esc` returns to `"none"`. Switching chats resets to `"none"`. The content +container gets `hl-armed` when `tool === "highlight"` and `cut-armed` (crosshair) when +`tool === "cut"`. + +### Block source positions (extend `rehypeSourcePositions`) + +The plugin already wraps text nodes in `<span class="md-pos" data-md-start/end>`. Extend +it so that, in the same walk, every **block element** in this set — +`p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, table, hr` — that has +`position.start.offset` also gets `data-md-block-start="<offset>"`. (Text-node wrapping +is unchanged; this only adds an attribute to block elements.) `li` block-start includes +the list marker (`- `, `1. `), so cutting between list items splits the list cleanly. + +### Cut-point mapping (`src/lib/cut-dom.ts`, DOM) + +- `interface CutBoundary { y: number; offset: number }` — `y` is in the scroll-content + coordinate space of the container; `offset` is the `content_md` offset of the block + **below** the gap. +- `getCutBoundaries(container): CutBoundary[]` — collect elements with + `data-md-block-start` in document order; for each consecutive pair `(a, b)`, emit + `{ y: midpoint of the gap between a.bottom and b.top, offset: b's block-start }`. + Coordinates are computed relative to the container's content + (`rect.top - container rect.top + container.scrollTop`) so they are stable across + scroll. +- `nearestBoundary(boundaries, mouseYInContent): CutBoundary | null` — the boundary with + `y` closest to the mouse. + +### Pure markdown ops (`src/lib/cut.ts`, DOM-free, unit-tested) + +- `deleteAbove(md, offset): string` → `md.slice(offset)` with leading whitespace trimmed. +- `deleteBelow(md, offset): string` → `md.slice(0, offset)` with trailing whitespace trimmed. +- `splitMarkdown(md, offset): { above: string; below: string }` → trimmed halves. +- `deriveSplitTitle(belowMd, originalTitle): string` → text of the first markdown heading + in `belowMd` (`/^#{1,6}\s+(.+?)\s*#*\s*$/m` on the first matching line), else + `"<originalTitle> (2)"`. + +### Split persistence (`useDatabase.splitChat`) + +```ts +splitChat(chatId: string, offset: number): Promise<Chat> // returns the new (below) note +``` + +1. Load the chat; compute `{ above, below } = splitMarkdown(chat.content_md, offset)`. +2. Insert a new chat via `db.insertChat`: `title = deriveSplitTitle(below, chat.title)`, + `summary = null`, `source = chat.source`, `content_md = below`, `content_html = null`, + `imported_at = now`, `chat_date = chat.chat_date`, `folder_id = chat.folder_id`. +3. Copy tags: for each tag of the original (`db.getTagsForChat`), `db.addTagToChat(new.id, tag.id)`. +4. Update the current chat: `db.updateChat(chatId, { content_md: above })`. +5. Refresh chats/tags/folders; keep the current chat selected. Return the new chat. + +## UX & components + +- **`MarkdownToolbar`**: now renders two tool buttons — the existing highlighter and a + new **scissors** button. Each toggles its mode; the active one is visually pressed. +- **Cut mode active**: crosshair cursor over the content; on `mousemove` the guide line + (a full-width absolutely-positioned element inside the content container) moves to the + nearest gap. The container needs `position: relative` for the overlay. +- **Click** (left) on the content while in cut mode opens a small **menu** positioned at + the click, with *Delete above* / *Delete below* / *Split*, acting on the currently + shown boundary. The menu closes on outside click, `Esc`, or after an action. +- **Delete above/below**: a confirmation (`Delete the part above/below this line?`) → + on confirm, `onUpdateChat(chat.id, { content_md })` and show an **Undo** toast for ~6s. + Undo restores the saved previous `content_md`. The saved snapshot is dropped when the + timer fires or the chat changes. +- **Split**: `splitChat(chat.id, offset)`; stay on the current note; show a toast + `Note split → Open new note` linking to the created note. Non-destructive, no confirm. + +### Files + +- **Change** `src/lib/highlight.ts` — extend `rehypeSourcePositions` to annotate blocks. +- **Create** `src/lib/cut.ts` — pure markdown ops + title derivation (unit-tested). +- **Create** `src/lib/cut-dom.ts` — `getCutBoundaries`, `nearestBoundary`. +- **Create** `src/components/ChatDetail/CutOverlay.tsx` — the guide line + action menu. +- **Change** `src/components/ChatDetail/MarkdownToolbar.tsx` — two tools, `tool` prop. +- **Change** `src/components/ChatDetail/ChatDetail.tsx` — `ToolMode` state, cut handlers, + confirm/undo/toast, render `CutOverlay` in both normal and focus views. +- **Change** `src/hooks/useDatabase.ts` — `splitChat`; thread it to `ChatDetail`. +- **Change** `src/index.css` — scissors button, crosshair, guide line, menu, toast. + +## Edge cases & caveats + +- **Code/mermaid blocks** are single cut units: you can cut around them, not inside. +- **De-risk first:** confirm block elements carry `position.start.offset` after + `rehype-raw` (a `bun test` over the real pipeline, like the highlight de-risk). +- **Undo window:** the pre-delete `content_md` is held only until the timer fires or the + chat changes; after that the only recovery is a snapshot. +- **Boundary trimming:** slicing at a block-start and trimming whitespace keeps both + halves valid markdown; the inter-block blank line is dropped from whichever side loses it. +- **Empty results are prevented** because the line only appears between two blocks. +- **Search index:** `splitChat` and deletes flow through `db.insertChat`/`db.updateChat`, + which already re-index (with `stripHighlights`). No extra work. +- **Highlights across the cut:** a `<mark>` fully on one side moves with that side; a + `<mark>` that the cut would bisect is rare (cuts are at block boundaries, marks are + within a block) — not specially handled in v1. + +## Non-goals (v1) + +- Cutting inside a paragraph/code block (sub-block precision). +- Undo for Split (it is non-destructive; recover by merging manually). +- AI re-analysis of the split-off note. +- Merging two notes back together (the inverse of split). + +## Testing strategy + +- **Unit (`cut.ts`):** `deleteAbove`/`deleteBelow`/`splitMarkdown` trimming; `deriveSplitTitle` + with/without a leading heading. +- **De-risk test:** block elements have source offsets after the real pipeline. +- **Plugin test:** blocks (incl. `li`) get `data-md-block-start` matching the source. +- **Manual:** snap line between paragraphs / list items / around a code block; delete + above/below with confirm + undo; split and verify the new note inherits source/folder/ + tags and a sensible title; tool mutual-exclusivity and `Esc`. diff --git a/src/App.tsx b/src/App.tsx index c07802b..832e4e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,7 +56,7 @@ export default function App() { const { chats, recentChats, tags, folders, unfiledCount, selectedChat, selectedChatTags, selectedChatAttachments, searchQuery, selectedTagIds, selectedSource, selectedFolderId, loading, generatingMetadata, - setSelectedChat, checkDuplicate, updateExistingChat, importFile, updateChat, toggleFavorite, deleteChat, + setSelectedChat, checkDuplicate, updateExistingChat, importFile, splitChat, updateChat, toggleFavorite, deleteChat, createTag, updateTag, deleteTag, addTagToChat, removeTagFromChat, addAttachment, removeAttachment, toggleTag, selectTag, clearTags, selectSource, search, @@ -496,6 +496,11 @@ export default function App() { isResizing={isResizing} focusMode={focusMode} onToggleFocus={() => setFocusMode(f => !f)} + onSplitChat={splitChat} + onOpenChat={(id) => { + const c = chats.find((x) => x.id === id); + if (c) setSelectedChat(c); + }} /> </> ) : !focusMode && filteredChats.length > 0 ? ( diff --git a/src/components/ChatDetail/ChatDetail.tsx b/src/components/ChatDetail/ChatDetail.tsx index 72f0c56..0cca161 100644 --- a/src/components/ChatDetail/ChatDetail.tsx +++ b/src/components/ChatDetail/ChatDetail.tsx @@ -7,7 +7,9 @@ import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; import { rehypeSourcePositions, applyHighlight, removeHighlight, newHighlightId, stripHighlights } from "../../lib/highlight"; -import MarkdownToolbar from "./MarkdownToolbar"; +import { deleteAbove, deleteBelow } from "../../lib/cut"; +import MarkdownToolbar, { type ToolMode } from "./MarkdownToolbar"; +import CutOverlay from "./CutOverlay"; import { computeSourceRanges } from "../../lib/highlight-dom"; import { extractHeadings } from "../../lib/parser"; import { jsxToHtml } from "../../lib/jsx-preview"; @@ -142,6 +144,8 @@ interface ChatDetailProps { isResizing?: boolean; focusMode?: boolean; onToggleFocus?: () => void; + onSplitChat: (chatId: string, offset: number) => Promise<Chat | null>; + onOpenChat: (chatId: string) => void; } function getTextContent(node: React.ReactNode): string { @@ -173,7 +177,7 @@ const sourceLabels: Record<string, string> = { claude: "Claude", perplexity: "Pe export default function ChatDetail({ chat, tags, allTags, attachments, onUpdateChat, onClose, - onAddTag, onRemoveTag, onCreateTag, onAddAttachment, onRemoveAttachment, onRegenerateField, onReparseHtml, isResizing, focusMode, onToggleFocus, + onAddTag, onRemoveTag, onCreateTag, onAddAttachment, onRemoveAttachment, onRegenerateField, onReparseHtml, isResizing, focusMode, onToggleFocus, onSplitChat, onOpenChat, }: ChatDetailProps) { const [editingTitle, setEditingTitle] = useState(false); const [titleValue, setTitleValue] = useState(chat.title); @@ -196,8 +200,13 @@ export default function ChatDetail({ const hlNoticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const tagDropdownRef = useRef<HTMLDivElement>(null); const tocDragging = useRef(false); - const [armed, setArmed] = useState(false); + const [tool, setTool] = useState<ToolMode>("none"); const [hlNotice, setHlNotice] = useState<string | null>(null); + const [cutConfirm, setCutConfirm] = useState<{ direction: "above" | "below"; offset: number } | null>(null); + const [cutUndo, setCutUndo] = useState<string | null>(null); // previous content_md + const [splitToast, setSplitToast] = useState<string | null>(null); // new chat id + const cutUndoTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + const splitToastTimer = useRef<ReturnType<typeof setTimeout> | null>(null); useEffect(() => { setEditingTitle(false); @@ -207,9 +216,14 @@ export default function ChatDetail({ setShowTagDropdown(false); setShowChatSearch(false); setChatSearchTerm(""); - setArmed(false); + setTool("none"); setHlNotice(null); if (hlNoticeTimer.current) { clearTimeout(hlNoticeTimer.current); hlNoticeTimer.current = null; } + setCutConfirm(null); + setCutUndo(null); + setSplitToast(null); + if (cutUndoTimer.current) { clearTimeout(cutUndoTimer.current); cutUndoTimer.current = null; } + if (splitToastTimer.current) { clearTimeout(splitToastTimer.current); splitToastTimer.current = null; } }, [chat.id]); // Sync local state when chat data changes externally (e.g. after analysis) @@ -319,10 +333,43 @@ export default function ChatDetail({ const handleTitleSave = useCallback(() => { onUpdateChat(chat.id, { title: titleValue }); setEditingTitle(false); }, [chat.id, titleValue, onUpdateChat]); const handleSummarySave = useCallback(() => { onUpdateChat(chat.id, { summary: summaryValue }); }, [chat.id, summaryValue, onUpdateChat]); - // Toggle the highlighter on/off (like picking up / putting down a marker). - const handleToggleHighlighter = useCallback(() => { - setArmed((prev) => !prev); + const handleToggleHighlight = useCallback(() => { + setTool((t) => (t === "highlight" ? "none" : "highlight")); }, []); + const handleToggleCut = useCallback(() => { + setTool((t) => (t === "cut" ? "none" : "cut")); + }, []); + + const performDelete = useCallback((direction: "above" | "below", offset: number) => { + const prev = chat.content_md; + const next = direction === "above" ? deleteAbove(prev, offset) : deleteBelow(prev, offset); + onUpdateChat(chat.id, { content_md: next }); + setCutUndo(prev); + if (cutUndoTimer.current) clearTimeout(cutUndoTimer.current); + cutUndoTimer.current = setTimeout(() => setCutUndo(null), 6000); + // Clear any pending split toast so only one toast is live at a time. + setSplitToast(null); + if (splitToastTimer.current) { clearTimeout(splitToastTimer.current); splitToastTimer.current = null; } + }, [chat.id, chat.content_md, onUpdateChat]); + + const handleCutUndo = useCallback(() => { + if (cutUndo == null) return; + onUpdateChat(chat.id, { content_md: cutUndo }); + setCutUndo(null); + if (cutUndoTimer.current) { clearTimeout(cutUndoTimer.current); cutUndoTimer.current = null; } + }, [cutUndo, chat.id, onUpdateChat]); + + const handleSplit = useCallback(async (offset: number) => { + const newChat = await onSplitChat(chat.id, offset); + if (newChat) { + setSplitToast(newChat.id); + if (splitToastTimer.current) clearTimeout(splitToastTimer.current); + splitToastTimer.current = setTimeout(() => setSplitToast(null), 8000); + // Clear any pending delete-undo toast so only one toast is live at a time. + setCutUndo(null); + if (cutUndoTimer.current) { clearTimeout(cutUndoTimer.current); cutUndoTimer.current = null; } + } + }, [chat.id, onSplitChat]); // Clicking an existing highlight erases it. const handleMarkRemove = useCallback((id: string) => { @@ -332,7 +379,7 @@ export default function ChatDetail({ // Armed-highlighter: while the marker is on, finishing a drag-selection in the // content applies a yellow highlight — no need to click a button afterwards. useEffect(() => { - if (!armed) return; + if (tool !== "highlight") return; const container = contentRef.current; if (!container) return; const onMouseUp = () => { @@ -350,15 +397,14 @@ export default function ChatDetail({ }; container.addEventListener("mouseup", onMouseUp); return () => container.removeEventListener("mouseup", onMouseUp); - }, [armed, chat.id, chat.content_md, onUpdateChat, focusMode]); + }, [tool, chat.id, chat.content_md, onUpdateChat, focusMode]); - // Esc turns the highlighter off. useEffect(() => { - if (!armed) return; - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setArmed(false); }; + if (tool === "none") return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setTool("none"); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [armed]); + }, [tool]); const handleAttachFile = useCallback(async () => { const selected = await open({ multiple: false, directory: false }); @@ -452,6 +498,36 @@ export default function ChatDetail({ const importDate = new Date(chat.imported_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); + const cutModals = ( + <> + {cutConfirm && ( + <div className="expand-modal-overlay" onClick={() => setCutConfirm(null)}> + <div className="cut-confirm" onClick={(e) => e.stopPropagation()}> + <div className="cut-confirm-text"> + Delete the part {cutConfirm.direction} this line? + </div> + <div className="cut-confirm-actions"> + <button className="cut-confirm-cancel" onClick={() => setCutConfirm(null)}>Cancel</button> + <button className="cut-confirm-delete" onClick={() => { performDelete(cutConfirm.direction, cutConfirm.offset); setCutConfirm(null); }}>Delete</button> + </div> + </div> + </div> + )} + {cutUndo != null && ( + <div className="cut-toast"> + <span>Content deleted</span> + <button onClick={handleCutUndo}>Undo</button> + </div> + )} + {splitToast && ( + <div className="cut-toast"> + <span>Note split</span> + <button onClick={() => { onOpenChat(splitToast); setSplitToast(null); }}>Open new note</button> + </div> + )} + </> + ); + if (focusMode) { return ( <div className="focus-mode"> @@ -461,15 +537,26 @@ export default function ChatDetail({ </svg> </button> <div className="focus-content"> - <div ref={contentRef} className={`md-content${armed ? " hl-armed" : ""}`}> + <div ref={contentRef} className={`md-content${tool === "highlight" ? " hl-armed" : tool === "cut" ? " cut-armed" : ""}`}> <MarkdownToolbar - armed={armed} + tool={tool} notice={hlNotice} - onToggle={handleToggleHighlighter} + onToggleHighlight={handleToggleHighlight} + onToggleCut={handleToggleCut} /> + {tool === "cut" && ( + <CutOverlay + containerRef={contentRef} + contentMd={chat.content_md} + onDeleteAbove={(offset) => setCutConfirm({ direction: "above", offset })} + onDeleteBelow={(offset) => setCutConfirm({ direction: "below", offset })} + onSplit={(offset) => handleSplit(offset)} + /> + )} <MemoizedMarkdown content={chat.content_md} contentRef={contentRef} onMarkClick={handleMarkRemove} /> </div> </div> + {cutModals} </div> ); } @@ -745,12 +832,22 @@ export default function ChatDetail({ }} /> </> )} - <div ref={contentRef} className={`md-content detail-content-main${armed ? " hl-armed" : ""}`}> + <div ref={contentRef} className={`md-content detail-content-main${tool === "highlight" ? " hl-armed" : tool === "cut" ? " cut-armed" : ""}`}> <MarkdownToolbar - armed={armed} + tool={tool} notice={hlNotice} - onToggle={handleToggleHighlighter} + onToggleHighlight={handleToggleHighlight} + onToggleCut={handleToggleCut} /> + {tool === "cut" && ( + <CutOverlay + containerRef={contentRef} + contentMd={chat.content_md} + onDeleteAbove={(offset) => setCutConfirm({ direction: "above", offset })} + onDeleteBelow={(offset) => setCutConfirm({ direction: "below", offset })} + onSplit={(offset) => handleSplit(offset)} + /> + )} {isResizing || tocResizing ? ( <div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 8, color: "var(--text-faint)" }}> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> @@ -804,6 +901,7 @@ export default function ChatDetail({ </div> </div> )} + {cutModals} </div> ); } diff --git a/src/components/ChatDetail/CutOverlay.tsx b/src/components/ChatDetail/CutOverlay.tsx new file mode 100644 index 0000000..6a60e37 --- /dev/null +++ b/src/components/ChatDetail/CutOverlay.tsx @@ -0,0 +1,99 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { getCutBoundaries, nearestBoundary, type CutBoundary } from "../../lib/cut-dom"; + +interface CutOverlayProps { + containerRef: React.RefObject<HTMLDivElement | null>; + contentMd: string; + onDeleteAbove: (offset: number) => void; + onDeleteBelow: (offset: number) => void; + onSplit: (offset: number) => void; +} + +export default function CutOverlay({ containerRef, contentMd, onDeleteAbove, onDeleteBelow, onSplit }: CutOverlayProps) { + const boundaries = useRef<CutBoundary[]>([]); + const [lineY, setLineY] = useState<number | null>(null); + const [menu, setMenu] = useState<{ x: number; y: number; offset: number } | null>(null); + const current = useRef<CutBoundary | null>(null); + + const recompute = useCallback(() => { + const c = containerRef.current; + if (c) boundaries.current = getCutBoundaries(c); + }, [containerRef]); + + // Recompute boundaries when the tool opens, the content changes, or layout shifts. + // Note: the scroll listener on containerRef.current may be inert in layouts where the + // actual scroll container is an ancestor (e.g. wide/focus view). This is intentional and + // safe: both the boundary `y` values and the mouse `yInContent` are computed relative to + // containerRef.current's live getBoundingClientRect().top, and .cut-line is absolutely + // positioned inside that same container — so the values stay internally consistent even + // while the page is scrolled. Do NOT "fix" this by switching to a window scroll listener + // or subtracting scrollTop from lineY; doing so would break the wide-view case. + useEffect(() => { + recompute(); + const c = containerRef.current; + if (!c) return; + c.addEventListener("scroll", recompute); + window.addEventListener("resize", recompute); + return () => { + c.removeEventListener("scroll", recompute); + window.removeEventListener("resize", recompute); + }; + }, [recompute, contentMd]); + + useEffect(() => { + const c = containerRef.current; + if (!c) return; + const onMove = (e: MouseEvent) => { + if (menu) return; // freeze the line while the menu is open + const rect = c.getBoundingClientRect(); + const yInContent = e.clientY - rect.top + c.scrollTop; + const b = nearestBoundary(boundaries.current, yInContent); + current.current = b; + setLineY(b ? b.y : null); + }; + const onClick = (e: MouseEvent) => { + if (!current.current) return; + e.preventDefault(); + setMenu({ x: e.clientX, y: e.clientY, offset: current.current.offset }); + }; + c.addEventListener("mousemove", onMove); + c.addEventListener("click", onClick); + return () => { + c.removeEventListener("mousemove", onMove); + c.removeEventListener("click", onClick); + }; + }, [containerRef, menu]); + + // Close the menu on Escape or outside click. + useEffect(() => { + if (!menu) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenu(null); }; + const onDown = (e: MouseEvent) => { + if (!(e.target as HTMLElement)?.closest(".cut-menu")) setMenu(null); + }; + window.addEventListener("keydown", onKey, true); + window.addEventListener("mousedown", onDown, true); + return () => { + window.removeEventListener("keydown", onKey, true); + window.removeEventListener("mousedown", onDown, true); + }; + }, [menu]); + + const act = (fn: (offset: number) => void) => { + if (menu) fn(menu.offset); + setMenu(null); + }; + + return ( + <> + {lineY != null && <div className="cut-line" style={{ top: lineY }} />} + {menu && ( + <div className="cut-menu" style={{ left: menu.x, top: menu.y }}> + <button type="button" onClick={() => act(onDeleteAbove)}>Delete above</button> + <button type="button" onClick={() => act(onDeleteBelow)}>Delete below</button> + <button type="button" onClick={() => act(onSplit)}>Split</button> + </div> + )} + </> + ); +} diff --git a/src/components/ChatDetail/MarkdownToolbar.tsx b/src/components/ChatDetail/MarkdownToolbar.tsx index a7cda46..be10fd1 100644 --- a/src/components/ChatDetail/MarkdownToolbar.tsx +++ b/src/components/ChatDetail/MarkdownToolbar.tsx @@ -1,25 +1,44 @@ +export type ToolMode = "none" | "highlight" | "cut"; + interface MarkdownToolbarProps { - armed: boolean; + tool: ToolMode; notice: string | null; - onToggle: () => void; + onToggleHighlight: () => void; + onToggleCut: () => void; } -export default function MarkdownToolbar({ armed, notice, onToggle }: MarkdownToolbarProps) { +export default function MarkdownToolbar({ tool, notice, onToggleHighlight, onToggleCut }: MarkdownToolbarProps) { return ( <div className="md-toolbar"> <button type="button" - className={`md-toolbar-btn${armed ? " armed" : ""}`} - title={armed ? "Highlighter on — drag over text to highlight (Esc to stop)" : "Highlighter — click, then drag over the text"} - aria-pressed={armed} - onClick={onToggle} + className={`md-toolbar-btn${tool === "highlight" ? " armed" : ""}`} + title={tool === "highlight" ? "Highlighter on — drag over text to highlight (Esc to stop)" : "Highlighter — click, then drag over the text"} + aria-pressed={tool === "highlight"} + onClick={onToggleHighlight} > <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="m9 11-6 6v3h9l3-3" /> <path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" /> </svg> </button> - {armed && <span className="md-toolbar-status">Drag to highlight · Esc to stop</span>} + <button + type="button" + className={`md-toolbar-btn${tool === "cut" ? " armed" : ""}`} + title={tool === "cut" ? "Cut on — click between blocks to delete or split (Esc to stop)" : "Cut — click, then pick a spot between blocks"} + aria-pressed={tool === "cut"} + onClick={onToggleCut} + > + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="6" cy="6" r="3" /> + <circle cx="6" cy="18" r="3" /> + <line x1="20" y1="4" x2="8.12" y2="15.88" /> + <line x1="14.47" y1="14.48" x2="20" y2="20" /> + <line x1="8.12" y1="8.12" x2="12" y2="12" /> + </svg> + </button> + {tool === "highlight" && <span className="md-toolbar-status">Drag to highlight · Esc to stop</span>} + {tool === "cut" && <span className="md-toolbar-status">Click between blocks · Esc to stop</span>} {notice && <span className="md-toolbar-notice">{notice}</span>} </div> ); diff --git a/src/hooks/useDatabase.ts b/src/hooks/useDatabase.ts index 8f8c091..2625a32 100644 --- a/src/hooks/useDatabase.ts +++ b/src/hooks/useDatabase.ts @@ -3,6 +3,7 @@ import type { Chat, Tag, TagWithCount, FolderWithCount, Attachment, Source } fro import * as db from '../lib/db'; import { parseImportFile } from '../lib/parser'; import { generateMetadata, ToolNotFoundError } from '../lib/metadata'; +import { splitMarkdown, deriveSplitTitle } from '../lib/cut'; import type { AnalysisSettings } from './useAnalysisSettings'; export function useDatabase() { @@ -227,6 +228,34 @@ export function useDatabase() { } }, [refreshChats, refreshTags, refreshFolders]); + const splitChat = useCallback(async (chatId: string, offset: number): Promise<Chat | null> => { + const all = await db.getAllChats(); + const chat = all.find((c) => c.id === chatId); + if (!chat) return null; + const { above, below } = splitMarkdown(chat.content_md, offset); + const newChat = await db.insertChat({ + title: deriveSplitTitle(below, chat.title), + summary: null, + source: chat.source, + content_md: below, + content_html: null, + imported_at: new Date().toISOString(), + chat_date: chat.chat_date, + folder_id: chat.folder_id, + deleted_at: null, + favorite: 0, + }); + const tags = await db.getTagsForChat(chatId); + for (const t of tags) await db.addTagToChat(newChat.id, t.id); + await db.updateChat(chatId, { content_md: above }); + await refreshChats(); + await refreshTags(); + await refreshFolders(); + const updatedCurrent = (await db.getAllChats()).find((c) => c.id === chatId); + if (updatedCurrent) setSelectedChat(updatedCurrent); + return newChat; + }, [refreshChats, refreshTags, refreshFolders, setSelectedChat]); + const updateChat = useCallback(async (id: string, updates: Partial<Chat>) => { await db.updateChat(id, updates); await refreshChats(); @@ -448,6 +477,7 @@ export function useDatabase() { checkDuplicate, updateExistingChat, importFile, + splitChat, updateChat, toggleFavorite, deleteChat, diff --git a/src/index.css b/src/index.css index b4552a1..a4d3111 100644 --- a/src/index.css +++ b/src/index.css @@ -1695,6 +1695,10 @@ mark.search-highlight.active { .md-content.hl-armed * { cursor: text; } +.md-content.cut-armed, +.md-content.cut-armed * { + cursor: crosshair; +} /* ---- SETTINGS MODAL ---- */ .settings-panel { @@ -2044,3 +2048,94 @@ mark.search-highlight.active { border-color: var(--text-faint); color: var(--text-primary); } + +/* Cut tool: the content container anchors the absolutely-positioned guide line */ +.md-content.cut-armed { + position: relative; + user-select: none; +} +.cut-line { + position: absolute; + left: 0; + right: 0; + height: 0; + border-top: 2px dashed var(--red); + pointer-events: none; + z-index: 6; +} +.cut-menu { + position: fixed; + z-index: 50; + display: flex; + flex-direction: column; + min-width: 140px; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 4px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); +} +.cut-menu button { + text-align: left; + background: none; + border: none; + color: var(--text-primary); + padding: 7px 10px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; +} +.cut-menu button:hover { + background: var(--bg-hover); +} +.cut-confirm { + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: 10px; + padding: 18px; + max-width: 320px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); +} +.cut-confirm-text { + color: var(--text-primary); + font-size: 14px; + margin-bottom: 14px; +} +.cut-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} +.cut-confirm-actions button { + border-radius: 6px; + padding: 6px 12px; + font-size: 13px; + cursor: pointer; + border: 1px solid var(--border-subtle); +} +.cut-confirm-cancel { background: none; color: var(--text-muted); } +.cut-confirm-delete { background: var(--red); border-color: var(--red); color: #fff; } +.cut-toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 60; + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 10px 14px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + font-size: 13px; + color: var(--text-primary); +} +.cut-toast button { + background: none; + border: none; + color: var(--accent, #2d6eb4); + font-weight: 600; + cursor: pointer; +} diff --git a/src/lib/cut-dom.test.ts b/src/lib/cut-dom.test.ts new file mode 100644 index 0000000..959c575 --- /dev/null +++ b/src/lib/cut-dom.test.ts @@ -0,0 +1,18 @@ +import { test, expect } from "bun:test"; +import { nearestBoundary, type CutBoundary } from "./cut-dom"; + +const bs: CutBoundary[] = [ + { y: 10, offset: 5 }, + { y: 50, offset: 20 }, + { y: 200, offset: 99 }, +]; + +test("nearestBoundary returns the closest boundary by y", () => { + expect(nearestBoundary(bs, 12)).toEqual({ y: 10, offset: 5 }); + expect(nearestBoundary(bs, 40)).toEqual({ y: 50, offset: 20 }); + expect(nearestBoundary(bs, 1000)).toEqual({ y: 200, offset: 99 }); +}); + +test("nearestBoundary returns null for an empty list", () => { + expect(nearestBoundary([], 5)).toBeNull(); +}); diff --git a/src/lib/cut-dom.ts b/src/lib/cut-dom.ts new file mode 100644 index 0000000..c8c9fe3 --- /dev/null +++ b/src/lib/cut-dom.ts @@ -0,0 +1,46 @@ +export interface CutBoundary { + y: number; // position in the container's scroll-content coordinate space + offset: number; // content_md offset of the block below the gap +} + +// Pure: the boundary whose y is closest to the target y. +export function nearestBoundary(boundaries: CutBoundary[], y: number): CutBoundary | null { + let best: CutBoundary | null = null; + let bestDist = Infinity; + for (const b of boundaries) { + const d = Math.abs(b.y - y); + if (d < bestDist) { + bestDist = d; + best = b; + } + } + return best; +} + +// DOM: gaps between consecutive cut units (top-level blocks and list items). +export function getCutBoundaries(container: HTMLElement): CutBoundary[] { + const isCutUnit = (el: HTMLElement): boolean => { + const p = el.parentElement; + return p === container || (!!p && (p.tagName === "UL" || p.tagName === "OL")); + }; + const containerTop = container.getBoundingClientRect().top; + const scrollTop = container.scrollTop; + const items = Array.from(container.querySelectorAll<HTMLElement>("[data-md-block-start]")) + .filter(isCutUnit) + .map((el) => { + const r = el.getBoundingClientRect(); + return { + top: r.top - containerTop + scrollTop, + bottom: r.bottom - containerTop + scrollTop, + offset: Number(el.dataset.mdBlockStart), + }; + }) + .filter((it) => !Number.isNaN(it.offset)) + .sort((a, b) => a.top - b.top); + + const boundaries: CutBoundary[] = []; + for (let i = 0; i < items.length - 1; i++) { + boundaries.push({ y: (items[i].bottom + items[i + 1].top) / 2, offset: items[i + 1].offset }); + } + return boundaries; +} diff --git a/src/lib/cut-plugin.test.ts b/src/lib/cut-plugin.test.ts new file mode 100644 index 0000000..f34d5a2 --- /dev/null +++ b/src/lib/cut-plugin.test.ts @@ -0,0 +1,46 @@ +import { test, expect } from "bun:test"; +import { unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkGfm from "remark-gfm"; +import remarkRehype from "remark-rehype"; +import rehypeRaw from "rehype-raw"; +import { rehypeSourcePositions } from "./highlight"; + +function toHast(md: string) { + const p = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw); + return p.runSync(p.parse(md)) as any; +} + +function collect(node: any, acc: any[] = []): any[] { + if (node.type === "element" && node.properties?.["data-md-block-start"] != null) acc.push(node); + for (const c of node.children ?? []) collect(c, acc); + return acc; +} + +test("block elements get data-md-block-start pointing at the block's source start", () => { + const md = "## Heading\n\nA paragraph.\n\n- first item\n- second item\n"; + const hast = toHast(md); + rehypeSourcePositions()(hast); + const blocks = collect(hast); + // tag -> source offset; verify the source at that offset begins the block. + const byTag: Record<string, number> = {}; + for (const b of blocks) byTag[b.tagName] ??= Number(b.properties["data-md-block-start"]); + + expect(md.startsWith("## Heading", byTag["h2"])).toBe(true); + expect(md.startsWith("A paragraph.", byTag["p"])).toBe(true); + // first li starts at the "- first item" marker + const liStart = Number(blocks.find((b) => b.tagName === "li").properties["data-md-block-start"]); + expect(md.startsWith("- first item", liStart)).toBe(true); +}); + +test("each list item gets its own block-start at its marker", () => { + const md = "- alpha\n- beta\n- gamma\n"; + const hast = toHast(md); + rehypeSourcePositions()(hast); + const lis = collect(hast).filter((b) => b.tagName === "li"); + expect(lis.length).toBe(3); + const starts = lis.map((b) => Number(b.properties["data-md-block-start"])).sort((a, b) => a - b); + expect(md.startsWith("- alpha", starts[0])).toBe(true); + expect(md.startsWith("- beta", starts[1])).toBe(true); + expect(md.startsWith("- gamma", starts[2])).toBe(true); +}); diff --git a/src/lib/cut.test.ts b/src/lib/cut.test.ts new file mode 100644 index 0000000..0afdeab --- /dev/null +++ b/src/lib/cut.test.ts @@ -0,0 +1,33 @@ +import { test, expect } from "bun:test"; +import { deleteAbove, deleteBelow, splitMarkdown, deriveSplitTitle } from "./cut"; + +const md = "# A\n\nbody text\n\n## B\n\nmore text\n"; +const at = md.indexOf("## B"); + +test("deleteAbove keeps from the offset, trimming leading whitespace", () => { + expect(deleteAbove(md, at)).toBe("## B\n\nmore text"); +}); + +test("deleteBelow keeps up to the offset, trimming trailing whitespace", () => { + expect(deleteBelow(md, at)).toBe("# A\n\nbody text"); +}); + +test("splitMarkdown returns trimmed above/below halves", () => { + expect(splitMarkdown(md, at)).toEqual({ above: "# A\n\nbody text", below: "## B\n\nmore text" }); +}); + +test("deriveSplitTitle uses the first heading in the part below", () => { + expect(deriveSplitTitle("## Section two\n\ntext", "Orig")).toBe("Section two"); +}); + +test("deriveSplitTitle finds a heading even if not on the first line", () => { + expect(deriveSplitTitle("intro line\n\n### Deep\n\nx", "Orig")).toBe("Deep"); +}); + +test("deriveSplitTitle falls back to '<title> (2)' when there is no heading", () => { + expect(deriveSplitTitle("just text, no heading", "Orig")).toBe("Orig (2)"); +}); + +test("deriveSplitTitle strips trailing closing hashes", () => { + expect(deriveSplitTitle("# Title #\n", "Orig")).toBe("Title"); +}); diff --git a/src/lib/cut.ts b/src/lib/cut.ts new file mode 100644 index 0000000..e64213d --- /dev/null +++ b/src/lib/cut.ts @@ -0,0 +1,20 @@ +export function deleteAbove(md: string, offset: number): string { + return md.slice(offset).replace(/^\s+/, "").replace(/\s+$/, ""); +} + +export function deleteBelow(md: string, offset: number): string { + return md.slice(0, offset).replace(/\s+$/, ""); +} + +export function splitMarkdown(md: string, offset: number): { above: string; below: string } { + return { + above: md.slice(0, offset).replace(/\s+$/, ""), + below: md.slice(offset).replace(/^\s+/, "").replace(/\s+$/, ""), + }; +} + +export function deriveSplitTitle(belowMd: string, originalTitle: string): string { + const m = belowMd.match(/^#{1,6}\s+(.+?)\s*#*\s*$/m); + if (m) return m[1].trim(); + return `${originalTitle} (2)`; +} diff --git a/src/lib/highlight-dom.ts b/src/lib/highlight-dom.ts index 12bb204..d00343d 100644 --- a/src/lib/highlight-dom.ts +++ b/src/lib/highlight-dom.ts @@ -30,7 +30,7 @@ export function computeSourceRanges(container: HTMLElement): SourceRange[] | nul const spans = container.querySelectorAll<HTMLElement>("span.md-pos"); const ranges: SourceRange[] = []; spans.forEach((span) => { - if (!nodeInSelection(sel, range, span)) return; + if (!nodeInSelection(range, span)) return; const mdStart = Number(span.dataset.mdStart); const mdEnd = Number(span.dataset.mdEnd); if (Number.isNaN(mdStart) || Number.isNaN(mdEnd)) return; @@ -50,16 +50,10 @@ export function computeSourceRanges(container: HTMLElement): SourceRange[] | nul return ranges.length ? ranges : null; } -// Whether `node` is at least partially within the selection. Selection.containsNode -// is the most reliable in WebKit/WKWebView; fall back to Range boundary geometry. -function nodeInSelection(sel: Selection, range: Range, node: Node): boolean { - if (typeof sel.containsNode === "function") { - try { - return sel.containsNode(node, true); - } catch { - /* fall through to geometry */ - } - } +// Whether `node` overlaps the selection range. Uses deterministic Range boundary +// geometry — Selection.containsNode is unreliable in WebKit/WKWebView (notably it +// can report false for spans inside list items). +function nodeInSelection(range: Range, node: Node): boolean { const nodeRange = document.createRange(); nodeRange.selectNodeContents(node); // Overlap iff range.start <= node.end AND range.end >= node.start. diff --git a/src/lib/highlight.ts b/src/lib/highlight.ts index fdb281a..53defd7 100644 --- a/src/lib/highlight.ts +++ b/src/lib/highlight.ts @@ -115,6 +115,7 @@ interface HastElement { tagName: string; properties?: Record<string, unknown>; children: HastChild[]; + position?: { start?: { offset?: number } }; } interface HastRoot { type: "root"; @@ -123,6 +124,7 @@ interface HastRoot { type HastChild = HastText | HastElement | { type: string; children?: HastChild[] }; const SKIP_TAGS = new Set(["code", "pre"]); +const BLOCK_TAGS = new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "pre", "table", "hr"]); export function rehypeSourcePositions() { return (tree: HastRoot) => walkHast(tree, false); @@ -135,6 +137,13 @@ function walkHast(node: HastRoot | HastElement, inSkip: boolean): void { const child = children[i]; if (child.type === "element") { const el = child as HastElement; + if (BLOCK_TAGS.has(el.tagName)) { + const blockStart = el.position?.start?.offset; + if (blockStart != null) { + el.properties = el.properties ?? {}; + el.properties["data-md-block-start"] = String(blockStart); + } + } walkHast(el, inSkip || SKIP_TAGS.has(el.tagName)); } else if (child.type === "text" && !inSkip) { const text = child as HastText;