diff --git a/app/package-lock.json b/app/package-lock.json index b2cbd57..98dafbb 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "sudonotes", - "version": "0.3.4", + "version": "0.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sudonotes", - "version": "0.3.4", + "version": "0.3.5", "dependencies": { "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.4", diff --git a/app/src/App.css b/app/src/App.css index 8b07048..4ec2249 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -2155,6 +2155,49 @@ mark.cm-find-match { word-break: break-word; } +/* Card vertical resize handle */ +.card-resize-handle { + position: relative; + height: 10px; + margin: 6px -13px -12px; + display: flex; + align-items: center; + justify-content: center; + cursor: row-resize; + user-select: none; + touch-action: none; + border-bottom-left-radius: 9px; + border-bottom-right-radius: 9px; + transition: background-color 150ms ease; + z-index: 1; +} + +.card-resize-handle:hover, +.card-resize-handle.active { + background: var(--type-soft, rgba(255, 255, 255, 0.04)); +} + +.card-resize-pill { + width: 32px; + height: 3px; + border-radius: 2px; + background-color: var(--border); + opacity: 0.4; + pointer-events: none; + transition: opacity 150ms ease, background-color 150ms ease, width 150ms ease; +} + +.card:hover .card-resize-pill { + opacity: 0.8; +} + +.card-resize-handle:hover .card-resize-pill, +.card-resize-handle.active .card-resize-pill { + opacity: 1; + width: 44px; + background-color: var(--type-color, var(--accent)); +} + .add-prompt-bottom { margin-top: 12px; padding: 7px 14px; diff --git a/app/src/components/PromptCards.test.tsx b/app/src/components/PromptCards.test.tsx new file mode 100644 index 0000000..4ca4b30 --- /dev/null +++ b/app/src/components/PromptCards.test.tsx @@ -0,0 +1,257 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { useStore } from "../store"; +import { PromptCards } from "./PromptCards"; +import { + clampPromptHeight, + loadPromptHeights, + MAX_PROMPT_HEIGHT, + MIN_PROMPT_HEIGHT, + PROMPT_HEIGHTS_STORAGE_KEY, + savePromptHeight, +} from "../promptHeightStorage"; + +// Silence React 19 act environment warning in tests +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("PromptCards vertical resizing & persistence", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + localStorage.clear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + useStore.setState({ + active: { + id: "col-1", + title: "Test Collection", + body: "", + tags: [], + type: "prompt", + created: "2026-08-01T00:00:00Z", + updated: "2026-08-01T00:00:00Z", + path: "Test Collection.md", + summary: null, + icon: null, + collection: null, + position: null, + models: {}, + model: null, + bubbleTags: {}, + project: null, + onHold: false, + }, + children: [ + { + id: "prompt-1", + title: "Prompt One", + body: "Line 1\nLine 2\nLine 3\nLine 4\nLine 5", + tags: ["test"], + model: null, + position: 1, + }, + { + id: "prompt-2", + title: "Prompt Two", + body: "Another prompt body content", + tags: [], + model: "claude-3-5-sonnet", + position: 2, + }, + ], + promptHeights: {}, + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + localStorage.clear(); + }); + + it("renders a vertical resize handle for each prompt card", async () => { + await act(async () => { + root.render(); + }); + + const card1 = container.querySelector("#prompt-card-prompt-1"); + expect(card1).not.toBeNull(); + + const handle = card1?.querySelector(".card-resize-handle"); + expect(handle).not.toBeNull(); + expect(handle?.getAttribute("aria-label")).toBe("Resize prompt vertically"); + }); + + it("restores saved custom heights from localStorage/store on initial render", async () => { + savePromptHeight("prompt-1", 340); + useStore.setState({ promptHeights: loadPromptHeights() }); + + await act(async () => { + root.render(); + }); + + const bodyEl = container.querySelector("#prompt-card-prompt-1 .card-body") as HTMLElement; + expect(bodyEl).not.toBeNull(); + expect(bodyEl.style.height).toBe("340px"); + expect(bodyEl.style.maxHeight).toBe("340px"); + }); + + it("persists height on vertical drag and enforces min/max clamping (70px - 700px)", async () => { + await act(async () => { + root.render(); + }); + + const handle = container.querySelector("#prompt-card-prompt-1 .card-resize-handle") as HTMLElement; + const bodyEl = container.querySelector("#prompt-card-prompt-1 .card-body") as HTMLElement; + const startHeight = bodyEl.getBoundingClientRect().height; + + // Simulate pointerdown + act(() => { + handle.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + cancelable: true, + button: 0, + clientY: 100, + }), + ); + }); + + // Simulate pointermove expanding height by +150px + act(() => { + window.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + clientY: 250, + }), + ); + }); + + // Verify live height updated in DOM + const expectedHeight = clampPromptHeight(startHeight + 150); + expect(bodyEl.style.height).toBe(`${expectedHeight}px`); + + // Simulate pointerup to commit + act(() => { + window.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + clientY: 250, + }), + ); + }); + + // Check store state and localStorage persistence + const stateHeights = useStore.getState().promptHeights; + expect(stateHeights["prompt-1"]).toBe(expectedHeight); + + const persisted = JSON.parse(localStorage.getItem(PROMPT_HEIGHTS_STORAGE_KEY) || "{}"); + expect(persisted["prompt-1"]).toBe(expectedHeight); + }); + + it("clamps resize to minimum 70px when dragged upwards excessively", async () => { + await act(async () => { + root.render(); + }); + + const handle = container.querySelector("#prompt-card-prompt-1 .card-resize-handle") as HTMLElement; + + act(() => { + handle.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + cancelable: true, + button: 0, + clientY: 300, + }), + ); + }); + + act(() => { + window.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + clientY: 0, + }), + ); + }); + + act(() => { + window.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + clientY: 0, + }), + ); + }); + + expect(useStore.getState().promptHeights["prompt-1"]).toBe(MIN_PROMPT_HEIGHT); + }); + + it("clamps resize to maximum 700px when dragged downwards excessively", async () => { + await act(async () => { + root.render(); + }); + + const handle = container.querySelector("#prompt-card-prompt-1 .card-resize-handle") as HTMLElement; + + act(() => { + handle.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + cancelable: true, + button: 0, + clientY: 100, + }), + ); + }); + + act(() => { + window.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + clientY: 1500, + }), + ); + }); + + act(() => { + window.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + clientY: 1500, + }), + ); + }); + + expect(useStore.getState().promptHeights["prompt-1"]).toBe(MAX_PROMPT_HEIGHT); + }); + + it("resets height to default on handle double-click", async () => { + savePromptHeight("prompt-1", 400); + useStore.setState({ promptHeights: loadPromptHeights() }); + + await act(async () => { + root.render(); + }); + + const handle = container.querySelector("#prompt-card-prompt-1 .card-resize-handle") as HTMLElement; + const bodyEl = container.querySelector("#prompt-card-prompt-1 .card-body") as HTMLElement; + + expect(bodyEl.style.height).toBe("400px"); + + act(() => { + handle.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true })); + }); + + expect(useStore.getState().promptHeights["prompt-1"]).toBeUndefined(); + expect(bodyEl.style.height).toBe(""); + }); +}); diff --git a/app/src/components/PromptCards.tsx b/app/src/components/PromptCards.tsx index 994035d..a0ac8b3 100644 --- a/app/src/components/PromptCards.tsx +++ b/app/src/components/PromptCards.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { api, type ChildPrompt } from "../api"; import { useStore } from "../store"; +import { clampPromptHeight, DEFAULT_PROMPT_HEIGHT } from "../promptHeightStorage"; import { useListDrag, reordered, type NoteDragHandlers } from "../useListDrag"; import { ModelPicker } from "./ModelPicker"; import { ProviderIcon, providerOf, shortModelName } from "./ProviderMarks"; @@ -130,6 +131,65 @@ function Card({ const allNotes = useStore((s) => s.notes); const [editing, setEditing] = useState(false); + const customHeight = useStore((s) => s.promptHeights[prompt.id]); + const setPromptHeight = useStore((s) => s.setPromptHeight); + const resetPromptHeight = useStore((s) => s.resetPromptHeight); + + const [liveHeight, setLiveHeight] = useState(null); + const [isResizing, setIsResizing] = useState(false); + const bodyPreRef = useRef(null); + const activeHeight = liveHeight ?? customHeight; + + const handleResizePointerDown = (event: React.PointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + + const handleEl = event.currentTarget; + try { + handleEl.setPointerCapture(event.pointerId); + } catch {} + + setIsResizing(true); + const startY = event.clientY; + const startHeight = bodyPreRef.current + ? bodyPreRef.current.getBoundingClientRect().height + : (activeHeight ?? DEFAULT_PROMPT_HEIGHT); + + let currentClamped = clampPromptHeight(startHeight); + + const handlePointerMove = (e: PointerEvent) => { + e.preventDefault(); + const deltaY = e.clientY - startY; + currentClamped = clampPromptHeight(startHeight + deltaY); + setLiveHeight(currentClamped); + }; + + const handlePointerUp = (e: PointerEvent) => { + e.preventDefault(); + try { + handleEl.releasePointerCapture(e.pointerId); + } catch {} + + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + + setIsResizing(false); + setLiveHeight(null); + setPromptHeight(prompt.id, currentClamped); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + }; + + const handleResizeDoubleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + resetPromptHeight(prompt.id); + setLiveHeight(null); + }; const [saving, setSaving] = useState(false); const [title, setTitle] = useState(prompt.title); const [body, setBody] = useState(prompt.body); @@ -235,7 +295,11 @@ function Card({ -
+        
           
         
@@ -254,6 +318,18 @@ function Card({ ))} )} + +
+ +
); } diff --git a/app/src/promptHeightStorage.test.ts b/app/src/promptHeightStorage.test.ts new file mode 100644 index 0000000..b38ecd7 --- /dev/null +++ b/app/src/promptHeightStorage.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clampPromptHeight, + clearPromptHeights, + loadPromptHeights, + MAX_PROMPT_HEIGHT, + MIN_PROMPT_HEIGHT, + PROMPT_HEIGHTS_STORAGE_KEY, + removePromptHeight, + savePromptHeight, +} from "./promptHeightStorage"; + +describe("promptHeightStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe("clampPromptHeight", () => { + it("clamps values below minimum (70px) to 70px", () => { + expect(clampPromptHeight(0)).toBe(MIN_PROMPT_HEIGHT); + expect(clampPromptHeight(-100)).toBe(MIN_PROMPT_HEIGHT); + expect(clampPromptHeight(69)).toBe(MIN_PROMPT_HEIGHT); + expect(clampPromptHeight(MIN_PROMPT_HEIGHT)).toBe(MIN_PROMPT_HEIGHT); + }); + + it("clamps values above maximum (700px) to 700px", () => { + expect(clampPromptHeight(701)).toBe(MAX_PROMPT_HEIGHT); + expect(clampPromptHeight(1200)).toBe(MAX_PROMPT_HEIGHT); + expect(clampPromptHeight(MAX_PROMPT_HEIGHT)).toBe(MAX_PROMPT_HEIGHT); + }); + + it("preserves valid heights between 70px and 700px", () => { + expect(clampPromptHeight(70)).toBe(70); + expect(clampPromptHeight(190)).toBe(190); + expect(clampPromptHeight(350)).toBe(350); + expect(clampPromptHeight(700)).toBe(700); + }); + + it("rounds fractional heights to nearest integer", () => { + expect(clampPromptHeight(120.4)).toBe(120); + expect(clampPromptHeight(120.6)).toBe(121); + }); + + it("handles invalid inputs like NaN safely", () => { + expect(clampPromptHeight(NaN)).toBe(MIN_PROMPT_HEIGHT); + expect(clampPromptHeight(undefined as unknown as number)).toBe(MIN_PROMPT_HEIGHT); + }); + }); + + describe("localStorage persistence", () => { + it("saves and loads customized prompt heights keyed by prompt ID", () => { + savePromptHeight("prompt-1", 250); + savePromptHeight("prompt-2", 480); + + const heights = loadPromptHeights(); + expect(heights["prompt-1"]).toBe(250); + expect(heights["prompt-2"]).toBe(480); + }); + + it("clamps height when saving", () => { + const saved1 = savePromptHeight("p-small", 30); + expect(saved1).toBe(MIN_PROMPT_HEIGHT); + + const saved2 = savePromptHeight("p-big", 9999); + expect(saved2).toBe(MAX_PROMPT_HEIGHT); + + const heights = loadPromptHeights(); + expect(heights["p-small"]).toBe(MIN_PROMPT_HEIGHT); + expect(heights["p-big"]).toBe(MAX_PROMPT_HEIGHT); + }); + + it("removes a specific prompt height", () => { + savePromptHeight("p-1", 200); + savePromptHeight("p-2", 300); + + removePromptHeight("p-1"); + + const heights = loadPromptHeights(); + expect(heights["p-1"]).toBeUndefined(); + expect(heights["p-2"]).toBe(300); + }); + + it("clears all prompt heights", () => { + savePromptHeight("p-1", 200); + savePromptHeight("p-2", 300); + + clearPromptHeights(); + + expect(loadPromptHeights()).toEqual({}); + expect(localStorage.getItem(PROMPT_HEIGHTS_STORAGE_KEY)).toBeNull(); + }); + + it("handles corrupted or invalid JSON in localStorage gracefully", () => { + localStorage.setItem(PROMPT_HEIGHTS_STORAGE_KEY, "invalid-json{{"); + expect(loadPromptHeights()).toEqual({}); + + localStorage.setItem(PROMPT_HEIGHTS_STORAGE_KEY, JSON.stringify({ "p-bad": "not-a-number", "p-out": 9999 })); + const heights = loadPromptHeights(); + expect(heights["p-bad"]).toBeUndefined(); + expect(heights["p-out"]).toBe(MAX_PROMPT_HEIGHT); + }); + }); +}); diff --git a/app/src/promptHeightStorage.ts b/app/src/promptHeightStorage.ts new file mode 100644 index 0000000..8f9e4ab --- /dev/null +++ b/app/src/promptHeightStorage.ts @@ -0,0 +1,89 @@ +export const MIN_PROMPT_HEIGHT = 70; +export const MAX_PROMPT_HEIGHT = 700; +export const DEFAULT_PROMPT_HEIGHT = 190; +export const PROMPT_HEIGHTS_STORAGE_KEY = "sudonotes:prompt-card-heights"; + +/** + * Constrain resizing vertically with sensible limits (min 70px, max 700px). + * Horizontal resizing is not permitted. + */ +export function clampPromptHeight(height: number): number { + if (typeof height !== "number" || Number.isNaN(height)) { + return MIN_PROMPT_HEIGHT; + } + return Math.min(MAX_PROMPT_HEIGHT, Math.max(MIN_PROMPT_HEIGHT, Math.round(height))); +} + +/** + * Load all saved customized prompt heights keyed by prompt ID from localStorage. + */ +export function loadPromptHeights(): Record { + if (typeof window === "undefined" || !window.localStorage) { + return {}; + } + try { + const raw = localStorage.getItem(PROMPT_HEIGHTS_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return {}; + + const validated: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "number" && !Number.isNaN(value)) { + validated[key] = clampPromptHeight(value); + } + } + return validated; + } catch { + return {}; + } +} + +/** + * Persist a customized height for a prompt ID in localStorage. + * Returns the clamped height that was stored. + */ +export function savePromptHeight(id: string, height: number): number { + const clamped = clampPromptHeight(height); + if (typeof window === "undefined" || !window.localStorage) { + return clamped; + } + try { + const map = loadPromptHeights(); + map[id] = clamped; + localStorage.setItem(PROMPT_HEIGHTS_STORAGE_KEY, JSON.stringify(map)); + } catch { + // Ignore storage quota or security errors gracefully + } + return clamped; +} + +/** + * Remove a customized height for a prompt ID from localStorage. + */ +export function removePromptHeight(id: string): void { + if (typeof window === "undefined" || !window.localStorage) { + return; + } + try { + const map = loadPromptHeights(); + delete map[id]; + localStorage.setItem(PROMPT_HEIGHTS_STORAGE_KEY, JSON.stringify(map)); + } catch { + // Ignore errors + } +} + +/** + * Clear all persisted prompt heights from localStorage. + */ +export function clearPromptHeights(): void { + if (typeof window === "undefined" || !window.localStorage) { + return; + } + try { + localStorage.removeItem(PROMPT_HEIGHTS_STORAGE_KEY); + } catch { + // Ignore errors + } +} diff --git a/app/src/store.ts b/app/src/store.ts index bbfe2b3..c50caea 100644 --- a/app/src/store.ts +++ b/app/src/store.ts @@ -1,3 +1,8 @@ +import { + loadPromptHeights, + removePromptHeight, + savePromptHeight, +} from "./promptHeightStorage"; import { create } from "zustand"; import { @@ -302,6 +307,12 @@ interface AppState { loadAiSettings: () => Promise; saveAiSettings: (enabled: boolean) => Promise; saveBubbleMetadataVisible: (visible: boolean) => Promise; + /** Customized vertical heights for prompt collection cards keyed by prompt ID. */ + promptHeights: Record; + /** Save customized vertical height for a prompt collection card. */ + setPromptHeight: (id: string, height: number) => void; + /** Reset customized vertical height for a prompt collection card to default. */ + resetPromptHeight: (id: string) => void; } export const useStore = create((set, get) => ({ @@ -326,6 +337,24 @@ export const useStore = create((set, get) => ({ hoverBubble: null, cursorBubble: null, hoverPrompt: null, + promptHeights: loadPromptHeights(), + setPromptHeight: (id, height) => { + const clamped = savePromptHeight(id, height); + set((s) => ({ + promptHeights: { + ...s.promptHeights, + [id]: clamped, + }, + })); + }, + resetPromptHeight: (id) => { + removePromptHeight(id); + set((s) => { + const next = { ...s.promptHeights }; + delete next[id]; + return { promptHeights: next }; + }); + }, dirty: false, error: null, notice: null, diff --git a/app/vitest.config.ts b/app/vitest.config.ts index 688c7c7..52dda58 100644 --- a/app/vitest.config.ts +++ b/app/vitest.config.ts @@ -1,9 +1,10 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "jsdom", include: ["src/**/*.test.{ts,tsx}"], setupFiles: ["src/test/setup.ts"], + pool: "threads", }, });