) => {
+ 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",
},
});