diff --git a/content/docs/api/0-26-2/index.mdx b/content/docs/api/0-26-2/index.mdx index 6281c1a..b0ae3d3 100644 --- a/content/docs/api/0-26-2/index.mdx +++ b/content/docs/api/0-26-2/index.mdx @@ -5,7 +5,7 @@ description: Memos API Reference (0.26.x) ## Overview -This reference matches the Memos `0.26.x` release line. +This reference matches the Memos `0.26.x` release. ## Base URL diff --git a/docs/issues/2026-04-01-scratchpad-editor-architecture/definition.md b/docs/issues/2026-04-01-scratchpad-editor-architecture/definition.md new file mode 100644 index 0000000..bcf0a5e --- /dev/null +++ b/docs/issues/2026-04-01-scratchpad-editor-architecture/definition.md @@ -0,0 +1,35 @@ +## Background & Context + +The `scratchpad` feature is a browser-based note board for creating free-positioned cards, attaching files, and saving selected cards into a Memos instance. The current implementation grew from a simple local card board into an infinite-canvas-like surface with attachment previews, Memos connectivity, and viewport controls. The feature now spans UI rendering, editor interactions, local persistence, IndexedDB attachment storage, and remote save orchestration. + +## Issue Statement + +Scratchpad editor behavior is distributed across page, hook, and component layers without a single document state boundary, causing viewport state, selection state, item mutations, persistence timing, attachment handling, and remote-save transitions to be updated through overlapping ad hoc callbacks instead of a unified editor model. + +## Current State + +- [src/hooks/use-scratchpad.ts](/Users/steven/Projects/usememos/dotcom/src/hooks/use-scratchpad.ts#L62) owns local document data, selection state, instance storage, attachment persistence, save orchestration, keyboard shortcuts, and UI flags in one hook. +- [src/hooks/use-scratchpad.ts](/Users/steven/Projects/usememos/dotcom/src/hooks/use-scratchpad.ts#L161) creates items directly inside the orchestration hook, while [src/hooks/use-scratchpad.ts](/Users/steven/Projects/usememos/dotcom/src/hooks/use-scratchpad.ts#L171) handles file ingestion and decides whether files attach to an existing item or create a new item. +- [src/hooks/use-scratchpad.ts](/Users/steven/Projects/usememos/dotcom/src/hooks/use-scratchpad.ts#L245) updates remote sync state inline with local item mutations and instance refresh logic. +- [src/components/scratch/workspace.tsx](/Users/steven/Projects/usememos/dotcom/src/components/scratch/workspace.tsx#L44) owns viewport state, pan/zoom math, coordinate transforms, local viewport persistence, paste handling, drag-and-drop routing, and canvas HUD rendering. +- [src/components/scratch/card-item.tsx](/Users/steven/Projects/usememos/dotcom/src/components/scratch/card-item.tsx#L47) owns card drag/resize interaction, textarea sizing, attachment preview loading from IndexedDB, and sync-status display. +- [src/app/scratchpad/page.tsx](/Users/steven/Projects/usememos/dotcom/src/app/scratchpad/page.tsx#L13) wires a large callback surface directly into `Workspace` and mixes top-bar UI with feature orchestration. +- [src/lib/scratch/storage.ts](/Users/steven/Projects/usememos/dotcom/src/lib/scratch/storage.ts#L226) persists items, but viewport persistence is not modeled alongside the document. [src/lib/scratch/storage.ts](/Users/steven/Projects/usememos/dotcom/src/lib/scratch/storage.ts#L275) clears items using a payload shape that differs from the versioned envelope used elsewhere. +- [src/lib/scratch/types.ts](/Users/steven/Projects/usememos/dotcom/src/lib/scratch/types.ts#L59) models cards and sync state, but there is no explicit editor/document or viewport type for the scratchpad feature. + +## Non-Goals + +- Do not redesign the Memos API protocol in [src/lib/scratch/api.ts](/Users/steven/Projects/usememos/dotcom/src/lib/scratch/api.ts). +- Do not introduce collaborative sync, undo/redo history, connectors, or freehand drawing in this change. +- Do not replace card rendering with HTML `` or WebGL rendering in this change. +- Do not change the existing persisted card schema in a way that invalidates stored user content. + +## Open Questions + +- Should viewport state persist with the document or remain transient UI state? (default: persist viewport locally with the document experience) +- Should scratchpad orchestration expose one public hook or multiple hooks at the page boundary? (default: keep one public feature hook composed from smaller internal hooks) +- Should attachment preview loading remain card-local or move to a shared cache layer? (default: keep previews card-local for now, but isolate them from editor state) + +## Scope + +L — the current state spans multiple files with cross-cutting editor behavior, no explicit editor/document model, and at least two viable architectural directions (continued callback accretion vs. reducer/editor refactor). diff --git a/docs/issues/2026-04-01-scratchpad-editor-architecture/design.md b/docs/issues/2026-04-01-scratchpad-editor-architecture/design.md new file mode 100644 index 0000000..8c17349 --- /dev/null +++ b/docs/issues/2026-04-01-scratchpad-editor-architecture/design.md @@ -0,0 +1,41 @@ +## References + +- [tldraw README](https://github.com/tldraw/tldraw) +- [tldraw Store docs](https://tldraw.dev/reference/store/Store) +- [React Flow: Panning and Zooming](https://reactflow.dev/learn/concepts/the-viewport) +- [Excalidraw API: excalidrawAPI](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api) + +## Industry Baseline + +tldraw positions an infinite-canvas app around a central engine and explicitly separates extensible canvas behavior from UI surface concerns; its README describes a “feature-complete infinite canvas engine” and “DOM canvas” support, while its Store docs define a central reactive container with history, schema validation, side effects, and scoped persistence. React Flow documents viewport as a first-class concept independent from nodes and recommends explicit pan/zoom/select control schemes rather than scroll-container behavior. Excalidraw exposes scene and app state separately through `updateScene`, `getSceneElements`, `getAppState`, and history access, which reflects a clear editor API boundary between document content and editor state. + +## Research Summary + +Across these projects, the recurring pattern is not “use `` everywhere,” but “define a central editor model.” Document content, viewport, and editor UI state are modeled explicitly; rendering technology is a downstream choice. The common baseline is a state container or editor API that owns mutations, while components consume selectors and dispatch commands. Viewport is handled as camera math rather than scroll offsets, and controls are treated as external UI around that camera. This fits the current scratchpad codebase because the main maintenance problem is architectural diffusion of state and commands, not raw rendering performance. + +## Design Goals + +- Scratchpad document mutations must flow through pure editor helpers or reducer actions so item creation, selection, z-index changes, and viewport updates are testable and inspectable without DOM access. +- Viewport state must be modeled explicitly and persisted through one storage abstraction rather than local component keys. +- `useScratchpad` must stop owning low-level item mutation rules directly; it should orchestrate persistence, attachment IO, and remote save flows on top of editor commands. +- Page-level components must consume a narrower surface than the current callback fan-out in [src/app/scratchpad/page.tsx](/Users/steven/Projects/usememos/dotcom/src/app/scratchpad/page.tsx). +- Existing persisted cards and instance configuration must continue loading without migration loss. + +## Non-Goals + +- Do not replace DOM card rendering with a vector or bitmap renderer. +- Do not add collaborative transport, operational transforms, or multiplayer presence. +- Do not add undo/redo history, lasso selection, or connectors in this refactor. +- Do not redesign Memos save semantics or attachment upload endpoints. + +## Proposed Design + +Introduce an editor layer under `src/lib/scratch/` that defines scratchpad document state, viewport state, reducer actions, selectors, and coordinate helpers. This layer will own item factories, dirty-state transitions, z-index sequencing, selection logic, and viewport math. The design follows the central-store pattern shown in tldraw’s Store docs and the scene/app-state split surfaced by Excalidraw’s API. + +Move viewport persistence into [src/lib/scratch/storage.ts](/Users/steven/Projects/usememos/dotcom/src/lib/scratch/storage.ts) so document-adjacent state is saved through one abstraction instead of ad hoc `localStorage` keys inside rendering components. Keep item persistence backward-compatible by preserving the existing item envelope and extending storage with a dedicated viewport API. + +Refactor [src/hooks/use-scratchpad.ts](/Users/steven/Projects/usememos/dotcom/src/hooks/use-scratchpad.ts) into a composition root over a smaller editor hook. The internal editor hook will manage reducer state, hydration, debounced persistence, and keyboard behavior. `useScratchpad` will remain the public feature hook and will compose editor commands with IndexedDB attachment IO and Memos instance/save orchestration. + +Thin [src/components/scratch/workspace.tsx](/Users/steven/Projects/usememos/dotcom/src/components/scratch/workspace.tsx) into a viewport interaction component that receives `viewport`, `setViewport`, item data, and editor commands via props. It will keep transient pointer-session state only. This matches React Flow’s viewport-first model and keeps camera logic reusable. + +Extract page-level chrome into a dedicated toolbar component so [src/app/scratchpad/page.tsx](/Users/steven/Projects/usememos/dotcom/src/app/scratchpad/page.tsx) becomes a composition shell instead of a feature controller. Keep attachment preview loading card-local for now, but isolate it from editor/document state because it is presentational asset hydration rather than core editor state. diff --git a/docs/issues/2026-04-01-scratchpad-editor-architecture/execution.md b/docs/issues/2026-04-01-scratchpad-editor-architecture/execution.md new file mode 100644 index 0000000..1508db4 --- /dev/null +++ b/docs/issues/2026-04-01-scratchpad-editor-architecture/execution.md @@ -0,0 +1,42 @@ +## Execution Log + +### T1: Add scratchpad editor primitives + +**Status**: Completed +**Files Changed**: +- `src/lib/scratch/types.ts` +- `src/lib/scratch/storage.ts` +- `src/lib/scratch/editor.ts` +- `src/lib/scratch/viewport.ts` +**Validation**: `pnpm exec tsc --noEmit` — PASS +**Path Corrections**: None +**Deviations**: None + +### T2: Move feature state into an editor hook + +**Status**: Completed +**Files Changed**: +- `src/hooks/use-scratchpad-editor.ts` +- `src/hooks/use-scratchpad.ts` +**Validation**: `pnpm exec tsc --noEmit` — PASS +**Path Corrections**: None +**Deviations**: None + +### T3: Thin page/workspace UI around editor state + +**Status**: Completed +**Files Changed**: +- `src/components/scratch/workspace.tsx` +- `src/components/scratch/scratchpad-toolbar.tsx` +- `src/app/scratchpad/page.tsx` +- `src/components/scratch/card-item.tsx` +- `docs/issues/2026-04-01-scratchpad-editor-architecture/definition.md` +- `docs/issues/2026-04-01-scratchpad-editor-architecture/design.md` +- `docs/issues/2026-04-01-scratchpad-editor-architecture/plan.md` +**Validation**: `pnpm exec biome check src/app/scratchpad/page.tsx src/components/scratch/workspace.tsx src/components/scratch/card-item.tsx src/components/scratch/scratchpad-toolbar.tsx src/hooks/use-scratchpad.ts src/hooks/use-scratchpad-editor.ts src/lib/scratch/editor.ts src/lib/scratch/viewport.ts src/lib/scratch/storage.ts src/lib/scratch/types.ts docs/issues/2026-04-01-scratchpad-editor-architecture/definition.md docs/issues/2026-04-01-scratchpad-editor-architecture/design.md docs/issues/2026-04-01-scratchpad-editor-architecture/plan.md && pnpm exec tsc --noEmit` — PASS +**Path Corrections**: None +**Deviations**: None + +## Completion Declaration + +All tasks completed successfully diff --git a/docs/issues/2026-04-01-scratchpad-editor-architecture/plan.md b/docs/issues/2026-04-01-scratchpad-editor-architecture/plan.md new file mode 100644 index 0000000..6e30af8 --- /dev/null +++ b/docs/issues/2026-04-01-scratchpad-editor-architecture/plan.md @@ -0,0 +1,63 @@ +## Task List + +T1: Add scratchpad editor primitives [L] — T2: Move feature state into an editor hook [M] — T3: Thin page/workspace UI around editor state [L] + +### T1: Add scratchpad editor primitives [L] + +**Objective**: Introduce a central scratchpad editor model for items, selection, and viewport. +**Size**: L +**Files**: +- Create: `src/lib/scratch/editor.ts` +- Create: `src/lib/scratch/viewport.ts` +- Modify: `src/lib/scratch/types.ts` +- Modify: `src/lib/scratch/storage.ts` +**Implementation**: +1. In `src/lib/scratch/types.ts`, add explicit viewport typing used by document/editor state. +2. In `src/lib/scratch/editor.ts`, add editor state, reducer actions, selectors, item factory helpers, dirty-state helpers, and z-index/selection logic. +3. In `src/lib/scratch/viewport.ts`, add pure pan/zoom and screen-to-canvas math helpers. +4. In `src/lib/scratch/storage.ts`, add viewport storage and align item clearing with the versioned item envelope. +**Boundaries**: Do not change Memos API behavior or card rendering markup here. +**Dependencies**: None +**Expected Outcome**: Scratchpad has reusable editor/document and viewport logic outside of React components. +**Validation**: `pnpm exec tsc --noEmit` — exits 0 + +### T2: Move feature state into an editor hook [M] + +**Objective**: Isolate scratchpad document state, hydration, persistence, and local editing commands behind a dedicated hook. +**Size**: M +**Files**: +- Create: `src/hooks/use-scratchpad-editor.ts` +- Modify: `src/hooks/use-scratchpad.ts` +**Implementation**: +1. In `src/hooks/use-scratchpad-editor.ts`, use the new reducer/state helpers to hydrate items and viewport, persist them, and expose item/viewport commands plus local keyboard/delete behavior. +2. In `src/hooks/use-scratchpad.ts`, compose the editor hook with instance storage and remote-save orchestration while shrinking direct document-mutation logic. +**Boundaries**: Do not redesign connection testing or remote save semantics. +**Dependencies**: T1 +**Expected Outcome**: `useScratchpad` becomes a composition root over a narrower editor command surface. +**Validation**: `pnpm exec tsc --noEmit` — exits 0 + +### T3: Thin page/workspace UI around editor state [L] + +**Objective**: Make scratchpad components consume explicit editor state instead of owning editor logic ad hoc. +**Size**: L +**Files**: +- Create: `src/components/scratch/scratchpad-toolbar.tsx` +- Modify: `src/components/scratch/workspace.tsx` +- Modify: `src/app/scratchpad/page.tsx` +- Modify: `src/components/scratch/card-item.tsx` +**Implementation**: +1. In `src/components/scratch/workspace.tsx`, consume external viewport state and pure viewport helpers while keeping only transient pointer-session state locally. +2. In `src/components/scratch/scratchpad-toolbar.tsx`, extract top-bar UI and menu chrome from the page component. +3. In `src/app/scratchpad/page.tsx`, reduce the page to feature composition. +4. In `src/components/scratch/card-item.tsx`, keep drag/resize behavior compatible with externally managed viewport scale. +**Boundaries**: Do not add new editor capabilities such as connectors, drawing tools, or collaboration. +**Dependencies**: T1, T2 +**Expected Outcome**: Page-level code is thinner and editor state flow is explicit at the component boundary. +**Validation**: `pnpm exec biome check src/app/scratchpad/page.tsx src/components/scratch/workspace.tsx src/components/scratch/card-item.tsx src/components/scratch/scratchpad-toolbar.tsx src/hooks/use-scratchpad.ts src/hooks/use-scratchpad-editor.ts src/lib/scratch/editor.ts src/lib/scratch/viewport.ts src/lib/scratch/storage.ts src/lib/scratch/types.ts && pnpm exec tsc --noEmit` — exits 0 + +## Out-of-Scope Tasks + +- Add undo/redo history +- Add lasso or marquee multi-select +- Replace DOM card rendering with `` or WebGL +- Add multiplayer or collaborative syncing diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/openapi/latest.yaml b/openapi/latest.yaml index 8da9e24..c516046 100644 --- a/openapi/latest.yaml +++ b/openapi/latest.yaml @@ -2396,7 +2396,9 @@ components: backgroundColor: allOf: - $ref: '#/components/schemas/Color' - description: Background color for the tag label. + description: |- + Optional background color for the tag label. + When unset, the default tag color is used. blurContent: type: boolean description: Whether memos with this tag should have their content blurred. diff --git a/src/app/scratchpad/page.tsx b/src/app/scratchpad/page.tsx index e2c3367..be1505b 100644 --- a/src/app/scratchpad/page.tsx +++ b/src/app/scratchpad/page.tsx @@ -1,12 +1,7 @@ "use client"; -import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; -import { HomeIcon, MonitorIcon, MoonIcon, SaveIcon, SettingsIcon, SunIcon, TrashIcon } from "lucide-react"; -import Image from "next/image"; -import Link from "next/link"; -import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; import { InstanceSetupForm } from "@/components/scratch/instance-setup-form"; +import { ScratchpadToolbar } from "@/components/scratch/scratchpad-toolbar"; import { Workspace } from "@/components/scratch/workspace"; import { useScratchpad } from "@/hooks/use-scratchpad"; @@ -30,16 +25,11 @@ export default function ScratchPage() { selectedItemIds, selectedSaveBlockReason, selectedSaveTitle, - saveItemsToStorage, setShowInstanceForm, + setViewport, showInstanceForm, + viewport, } = useScratchpad(); - const { theme, setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); if (!isClient) { return null; @@ -47,122 +37,23 @@ export default function ScratchPage() { return (
-
- {selectedItemIds.length > 0 && ( -
- {selectedItemIds.length} selected -
- - -
- )} - - - - - - - - - setShowInstanceForm(true)} - > - - Instance Settings - - -
-
{defaultInstance?.name || "No instance connected"}
- {defaultInstance && ( - <> -
{defaultInstanceStatusLabel}
-
{defaultInstanceVersion}
- - )} -
- - - - - - {mounted && theme === "light" && } - {mounted && theme === "dark" && } - {mounted && theme === "system" && } - {!mounted && } - Theme - - - - setTheme("light")} - > - - Light - - setTheme("dark")} - > - - Dark - - setTheme("system")} - > - - System - - - - - - - - - - - Back to Main Site - - -
-
-
-
+ setShowInstanceForm(true)} + onSaveSelected={handleSaveSelected} + />
diff --git a/src/components/scratch/card-item.tsx b/src/components/scratch/card-item.tsx index 1197d68..067acbe 100644 --- a/src/components/scratch/card-item.tsx +++ b/src/components/scratch/card-item.tsx @@ -1,20 +1,33 @@ "use client"; -import { motion } from "framer-motion"; +import { motion, useMotionValue } from "framer-motion"; import { FileIcon, LoaderIcon, XIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { getFile } from "@/lib/scratch/indexeddb"; +import { + beginPointerInteraction, + cancelPointerInteraction, + createIdlePointerInteractionState, + createPointerSession, + finishPointerInteraction, + getActivePointerInteraction, + getPointerSessionDelta, + hasPointerSessionExceededThreshold, + isPointerInteractionMode, + type PointerInteractionMap, + type PointerSession, +} from "@/lib/scratch/interactions"; import type { FileData, ScratchpadItem } from "@/lib/scratch/types"; interface CardItemProps { item: ScratchpadItem; + canvasScale: number; onUpdateBody: (id: string, body: string) => void; onUpdateLayout: (id: string, updates: Partial) => void; onDelete: (id: string) => void; onRemoveAttachment: (id: string, attachmentId: string) => void; isSelected?: boolean; onSelect: (ctrlKey: boolean) => void; - onDragComplete?: () => void; } interface AttachmentPreview { @@ -23,21 +36,60 @@ interface AttachmentPreview { previewUrl: string | null; } +interface DragSession extends PointerSession { + moved: boolean; +} + +interface ResizeSession extends PointerSession { + startWidth: number; + startHeight: number; + latestWidth: number; + latestHeight: number; +} + +interface CardInteractionMap extends PointerInteractionMap { + dragging: DragSession; + resizing: ResizeSession; +} + +function hashString(value: string): number { + let hash = 0; + + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + + return hash; +} + +function formatCardTime(date: Date): string { + return new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); +} + +function getCardRotation(item: ScratchpadItem): number { + const rotationBucket = (hashString(item.id) % 7) - 3; + return rotationBucket * 0.65; +} + function getCardRingClass(item: ScratchpadItem, isSelected?: boolean): string { if (isSelected) { - return "ring-2 ring-blue-500 dark:ring-blue-400 shadow-md"; + return "ring-2 ring-[#d0b449]/55 shadow-[0_30px_70px_rgba(145,120,41,0.2)]"; } if (item.sync.status === "error") { - return "ring-2 ring-red-500 dark:ring-red-400 shadow-md"; + return "ring-2 ring-red-300/80 shadow-[0_24px_60px_rgba(148,67,49,0.18)]"; } if (item.sync.status === "saving") { - return "ring-2 ring-amber-500 dark:ring-amber-400 shadow-md"; + return "ring-2 ring-amber-300/80 shadow-[0_24px_60px_rgba(163,116,38,0.18)]"; } if (item.sync.status === "synced") { - return "ring-2 ring-green-500 dark:ring-green-500 shadow-md"; + return "ring-2 ring-emerald-200/80 shadow-[0_24px_60px_rgba(108,125,87,0.14)]"; } return ""; @@ -45,26 +97,30 @@ function getCardRingClass(item: ScratchpadItem, isSelected?: boolean): string { export function CardItem({ item, + canvasScale, onUpdateBody, onUpdateLayout, onDelete, onRemoveAttachment, isSelected, onSelect, - onDragComplete, }: CardItemProps) { + const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); - const [resizeStart, setResizeStart] = useState({ - x: 0, - y: 0, - width: 0, - height: 0, - }); const [attachmentPreviews, setAttachmentPreviews] = useState([]); const textareaRef = useRef(null); + const dragOriginRef = useRef({ x: 0, y: 0 }); + const interactionRef = useRef(createIdlePointerInteractionState()); + const dragX = useMotionValue(0); + const dragY = useMotionValue(0); + const widthMotion = useMotionValue(item.width); + const heightMotion = useMotionValue(item.height); const MIN_WIDTH = 220; const MIN_HEIGHT = 170; + const cardRotation = useMemo(() => getCardRotation(item), [item]); + const cardTimestamp = useMemo(() => formatCardTime(item.updatedAt), [item.updatedAt]); + const hasImageAttachment = item.attachments.some((attachment) => attachment.type.startsWith("image/")); useEffect(() => { if (!textareaRef.current) return; @@ -72,6 +128,13 @@ export function CardItem({ textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; }, [item.body]); + useEffect(() => { + if (!isResizing) { + widthMotion.set(item.width); + heightMotion.set(item.height); + } + }, [heightMotion, isResizing, item.height, item.width, widthMotion]); + useEffect(() => { let cancelled = false; const urls: string[] = []; @@ -123,7 +186,6 @@ export function CardItem({ const handleContainerClick = (e: React.MouseEvent) => { e.stopPropagation(); - onSelect(e.ctrlKey || e.metaKey); }; const handleDoubleClick = (e: React.MouseEvent) => { @@ -138,7 +200,7 @@ export function CardItem({ } }; - const handleTextareaMouseDown = (e: React.MouseEvent) => { + const handleTextareaPointerDown = (e: React.PointerEvent) => { e.stopPropagation(); }; @@ -148,83 +210,201 @@ export function CardItem({ e.target.style.height = `${e.target.scrollHeight}px`; }; - const handleResizeMouseDown = (e: React.MouseEvent) => { + const finishDrag = (session: DragSession, clientX: number, clientY: number) => { + const deltaX = (clientX - session.startClientX) / canvasScale; + const deltaY = (clientY - session.startClientY) / canvasScale; + + if (session.moved) { + onUpdateLayout(item.id, { + x: dragOriginRef.current.x + deltaX, + y: dragOriginRef.current.y + deltaY, + }); + } + + dragX.set(0); + dragY.set(0); + setIsDragging(false); + }; + + const cancelDrag = () => { + dragX.set(0); + dragY.set(0); + setIsDragging(false); + }; + + const handleCardPointerDown = (e: React.PointerEvent) => { + if (e.button !== 0 || isPointerInteractionMode(interactionRef.current, "resizing")) { + return; + } + + e.stopPropagation(); + onSelect(e.ctrlKey || e.metaKey); + + dragOriginRef.current = { + x: item.x, + y: item.y, + }; + beginPointerInteraction(interactionRef, e.currentTarget, "dragging", { + ...createPointerSession(e.pointerId, e.clientX, e.clientY), + moved: false, + }); + dragX.set(0); + dragY.set(0); + setIsDragging(true); + }; + + const handleCardPointerMove = (e: React.PointerEvent) => { + const session = getActivePointerInteraction(interactionRef, "dragging", e.pointerId); + if (!session) { + return; + } + + const delta = getPointerSessionDelta(session, e.clientX, e.clientY); + + if (!session.moved && hasPointerSessionExceededThreshold(session, e.clientX, e.clientY, 2)) { + session.moved = true; + } + + dragX.set(delta.x / canvasScale); + dragY.set(delta.y / canvasScale); + }; + + const handleCardPointerUp = (e: React.PointerEvent) => { + const session = finishPointerInteraction(interactionRef, e.currentTarget, "dragging", e.pointerId); + if (!session) { + return; + } + + finishDrag(session, e.clientX, e.clientY); + }; + + const handleCardPointerCancel = (e: React.PointerEvent) => { + if (!cancelPointerInteraction(interactionRef, "dragging", e.pointerId)) { + return; + } + + cancelDrag(); + }; + + const finishResize = (session: ResizeSession) => { + onUpdateLayout(item.id, { + width: session.latestWidth, + height: session.latestHeight, + }); + setIsResizing(false); + }; + + const cancelResize = () => { + widthMotion.set(item.width); + heightMotion.set(item.height); + setIsResizing(false); + }; + + const handleResizePointerDown = (e: React.PointerEvent) => { e.stopPropagation(); e.preventDefault(); - setIsResizing(true); - setResizeStart({ - x: e.clientX, - y: e.clientY, - width: item.width, - height: item.height, + beginPointerInteraction(interactionRef, e.currentTarget, "resizing", { + ...createPointerSession(e.pointerId, e.clientX, e.clientY), + startWidth: item.width, + startHeight: item.height, + latestWidth: item.width, + latestHeight: item.height, }); + widthMotion.set(item.width); + heightMotion.set(item.height); + setIsResizing(true); }; - useEffect(() => { - if (!isResizing) return; + const handleResizePointerMove = (e: React.PointerEvent) => { + const session = getActivePointerInteraction(interactionRef, "resizing", e.pointerId); + if (!session) { + return; + } - const handleMouseMove = (e: MouseEvent) => { - const deltaX = e.clientX - resizeStart.x; - const deltaY = e.clientY - resizeStart.y; + const delta = getPointerSessionDelta(session, e.clientX, e.clientY); + const nextWidth = Math.max(MIN_WIDTH, session.startWidth + delta.x / canvasScale); + const nextHeight = Math.max(MIN_HEIGHT, session.startHeight + delta.y / canvasScale); - const newWidth = Math.max(MIN_WIDTH, resizeStart.width + deltaX); - const newHeight = Math.max(MIN_HEIGHT, resizeStart.height + deltaY); + session.latestWidth = nextWidth; + session.latestHeight = nextHeight; + widthMotion.set(nextWidth); + heightMotion.set(nextHeight); + }; - onUpdateLayout(item.id, { width: newWidth, height: newHeight }); - }; + const handleResizePointerUp = (e: React.PointerEvent) => { + const session = finishPointerInteraction(interactionRef, e.currentTarget, "resizing", e.pointerId); + if (!session) { + return; + } - const handleMouseUp = () => { - setIsResizing(false); - onDragComplete?.(); - }; + finishResize(session); + }; - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); + const handleResizePointerCancel = (e: React.PointerEvent) => { + if (!cancelPointerInteraction(interactionRef, "resizing", e.pointerId)) { + return; + } + + cancelResize(); + }; + useEffect(() => { return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); + interactionRef.current = createIdlePointerInteractionState(); }; - }, [isResizing, item.id, onDragComplete, onUpdateLayout, resizeStart]); + }, []); return ( { - e.stopPropagation(); - }} - onDrag={(_, info) => { - onUpdateLayout(item.id, { - x: item.x + info.delta.x, - y: item.y + info.delta.y, - }); - }} - onDragEnd={() => { - onDragComplete?.(); - }} + onPointerDown={handleCardPointerDown} + onPointerMove={handleCardPointerMove} + onPointerUp={handleCardPointerUp} + onPointerCancel={handleCardPointerCancel} onClick={handleContainerClick} onDoubleClick={handleDoubleClick} onKeyDown={handleKeyDown} tabIndex={0} - className={`absolute flex flex-col bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow focus:outline-none cursor-move ${getCardRingClass(item, isSelected)}`} + className={`absolute flex flex-col overflow-hidden rounded-[4px] border border-[#e5d57d] bg-[#fff2a8] text-stone-700 shadow-[0_18px_36px_rgba(120,101,38,0.18)] transition-[box-shadow,transform] duration-150 focus:outline-none ${ + isDragging ? "cursor-grabbing" : "cursor-grab" + } ${getCardRingClass(item, isSelected)}`} style={{ left: item.x, top: item.y, - width: item.width, - minHeight: item.height, + width: widthMotion, + minHeight: heightMotion, zIndex: item.zIndex || 1, - x: 0, - y: 0, + x: dragX, + y: dragY, }} - whileDrag={{ opacity: 0.5, cursor: "grabbing" }} + animate={ + isDragging + ? { + rotate: 0, + scale: 1.01, + boxShadow: "0 24px 54px rgba(90, 74, 53, 0.18)", + } + : { + rotate: isSelected ? 0 : cardRotation, + scale: 1, + boxShadow: "0 18px 36px rgba(120, 101, 38, 0.18)", + } + } + transition={{ type: "spring", stiffness: 420, damping: 34, mass: 0.45 }} title={item.sync.status === "synced" ? "Saved to Memos" : "Select and click save to save to Memos"} > +
+
+
+ +
+ Note + {cardTimestamp} +
+ {item.attachments.length > 0 && ( -
+
{item.attachments.map((attachment) => { const preview = previewMap.get(attachment.id); @@ -233,8 +413,10 @@ export function CardItem({ return (
e.stopPropagation()} + className={`group relative overflow-hidden rounded-[3px] border border-[#eadb8f]/85 bg-[#fff6bf]/72 p-1.5 ${ + isImage ? "pb-3" : "" + }`} + onPointerDown={(e) => e.stopPropagation()} > {isImage && preview?.previewUrl ? ( - {attachment.name} + <> +
+ {attachment.name} +
+
{attachment.name}
+ ) : ( -
- -
{attachment.name}
+
+ +
{attachment.name}
)}
@@ -263,32 +454,42 @@ export function CardItem({
)} -
+
+ {!hasImageAttachment && item.body.trim() && ( +
+ )}