diff --git a/docs/roadmap.md b/docs/roadmap.md index 6fed326..e040c55 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,8 +1,32 @@ --- title: "Roadmap" -last_updated: 2026-02-24 +last_updated: 2026-02-27 --- +## Export + +Multi-format document export with live preview, building on the existing `@react-pdf/renderer` pipeline and adding DOCX and plaintext output. + +### Tasks + +1. **PDF preview** + - Render the existing `MarkdownPdfDocument` component into an in-dialog preview pane (re-use `pdf().toBlob()` → `URL.createObjectURL`) + - Live-update preview when export options (page size, margins, font, header/footer) change; debounce re-renders + - Add page navigation controls (prev / next / page indicator) over the preview + - Show a loading skeleton while the preview is generating + - Wire preview into `PdfExportDialog` alongside the existing `ExportOptions` panel +2. **DOCX export** + - Add [`docx-rs`](https://github.com/bokuweb/docx-rs) as a Cargo dependency in `src-tauri/Cargo.toml` + - Implement a `markdown_to_docx` Tauri command that accepts the Markdown AST (or raw text), converts it to a `.docx` byte buffer using `docx-rs` (`Docx`, `Paragraph`, `Run`, heading levels, code blocks, lists, blockquotes), and returns `Vec` to the frontend + - Support basic formatting: bold, italic, code font, ordered/unordered lists, blockquotes, headings 1-3 + - Frontend receives the blob, prompts the Tauri `save` dialog with `.docx` filter, and writes with `writeFile` + - Add "DOCX" option to the export dialog alongside "PDF" +3. **Plaintext export** + - Strip all Markdown formatting from the raw document text (headings → plain text, remove `**`, `_`, `` ` ``, link syntax, etc.) + - Preserve logical structure: blank lines between paragraphs, indentation for list items, `---` for horizontal rules + - Save via Tauri `save` dialog with `.txt` filter + - Add "Plain Text" option to the export dialog + ## Content blocks (transclusion) Allow embedding external Markdown files, images, and CSV data into a master document using `/filename` syntax. diff --git a/src/__tests__/DocumentItem.test.tsx b/src/__tests__/DocumentItem.test.tsx index 7f753d5..cb736e1 100644 --- a/src/__tests__/DocumentItem.test.tsx +++ b/src/__tests__/DocumentItem.test.tsx @@ -111,6 +111,36 @@ describe("DocumentItem", () => { expect(screen.getByDisplayValue("test.md")).toBeInTheDocument(); }); + it("pins rename dialog near the operation trigger location", async () => { + const props = createProps(); + render(); + + const item = screen.getByText("Test Document"); + fireEvent.contextMenu(item, { clientX: 120, clientY: 180 }); + fireEvent.click(screen.getByRole("menuitem", { name: "Rename" })); + + await waitFor(() => { + const dialog = screen.getByRole("dialog", { name: "Rename document" }); + expect(dialog).toHaveStyle({ left: "132px", top: "192px" }); + }); + }); + + it("closes rename dialog on outside click", async () => { + const props = createProps(); + render(); + + const item = screen.getByText("Test Document"); + fireEvent.contextMenu(item); + fireEvent.click(screen.getByRole("menuitem", { name: "Rename" })); + + expect(screen.getByText("Rename Document")).toBeInTheDocument(); + fireEvent.pointerDown(document.body); + + await waitFor(() => { + expect(screen.queryByText("Rename Document")).not.toBeInTheDocument(); + }); + }); + it("calls onRenameDocument with new name", async () => { const onRenameDocument = vi.fn().mockResolvedValue(true); const props = createProps({ onRenameDocument }); diff --git a/src/__tests__/ports.test.ts b/src/__tests__/ports.test.ts index ec4319b..ca5aa3d 100644 --- a/src/__tests__/ports.test.ts +++ b/src/__tests__/ports.test.ts @@ -536,13 +536,13 @@ describe(runCmd, () => { describe("unknown command type", () => { it("should warn on unknown command type", async () => { - const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const warnSpy = vi.spyOn(logger, "warn").mockImplementation((_: string) => Promise.resolve()); const unknownCmd = { type: "Unknown" } as unknown as Cmd; await runCmd(unknownCmd); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown command type")); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"cmd":{"type":"Unknown"}')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("\"cmd\":{\"type\":\"Unknown\"}")); }); }); }); @@ -569,13 +569,13 @@ describe(SubscriptionManager, () => { }); it("should warn on unknown subscription type", async () => { - const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const warnSpy = vi.spyOn(logger, "warn").mockImplementation((_: string) => Promise.resolve()); const unknownSub = { type: "Unknown" } as unknown as Parameters[0]; await manager.subscribe(unknownSub); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown subscription type")); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"sub":{"type":"Unknown"}')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("\"sub\":{\"type\":\"Unknown\"}")); }); }); diff --git a/src/__tests__/useDocumentActions.test.ts b/src/__tests__/useDocumentActions.test.ts index c328eea..d9a7c27 100644 --- a/src/__tests__/useDocumentActions.test.ts +++ b/src/__tests__/useDocumentActions.test.ts @@ -17,7 +17,8 @@ describe("useDocumentActions", () => { dispatchEditor, createDraftTab: vi.fn(), createNewDocument, - })); + }) + ); act(() => { result.current.handleNewDocument({ type: "click" } as unknown as number); diff --git a/src/__tests__/usePdfExport.test.tsx b/src/__tests__/usePdfExport.test.tsx index 73db9c3..35e05b4 100644 --- a/src/__tests__/usePdfExport.test.tsx +++ b/src/__tests__/usePdfExport.test.tsx @@ -80,9 +80,11 @@ describe(usePdfExport, () => { expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( expect.stringContaining("PDF export custom font render failed; retrying with built-in fonts"), ); - expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(expect.stringContaining('"editorFontFamily":"IBM Plex Sans Variable"')); expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( - expect.stringContaining('"message":"Custom font failure without known substrings"'), + expect.stringContaining("\"editorFontFamily\":\"IBM Plex Sans Variable\""), + ); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + expect.stringContaining("\"message\":\"Custom font failure without known substrings\""), ); expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( expect.stringContaining("PDF export completed with built-in fonts after custom font failure"), @@ -109,7 +111,7 @@ describe(usePdfExport, () => { expect(vi.mocked(logger.error)).toHaveBeenCalledWith( expect.stringContaining("PDF export failed with both custom and built-in fonts"), ); - expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.stringContaining('"message":"custom failed"')); - expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.stringContaining('"message":"builtin failed"')); + expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.stringContaining("\"message\":\"custom failed\"")); + expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.stringContaining("\"message\":\"builtin failed\"")); }); }); diff --git a/src/__tests__/useWorkspaceController.test.tsx b/src/__tests__/useWorkspaceController.test.tsx index acb0451..6e0828d 100644 --- a/src/__tests__/useWorkspaceController.test.tsx +++ b/src/__tests__/useWorkspaceController.test.tsx @@ -4,23 +4,31 @@ import { resetAppStore, useAppStore } from "$state/stores/app"; import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("$ports", () => ({ - runCmd: vi.fn(async () => {}), - docList: vi.fn((_locationId: number, _onOk: (docs: unknown[]) => void, _onErr: (error: unknown) => void) => ({ - type: "None", - })), - docDelete: vi.fn(() => ({ type: "None" })), - docMove: vi.fn(() => ({ type: "None" })), - docRename: vi.fn(() => ({ type: "None" })), - locationAddViaDialog: vi.fn(() => ({ type: "None" })), - locationRemove: vi.fn(() => ({ type: "None" })), -})); +vi.mock( + "$ports", + () => ({ + runCmd: vi.fn(async () => {}), + docList: vi.fn((_locationId: number, _onOk: (docs: unknown[]) => void, _onErr: (error: unknown) => void) => ({ + type: "None", + })), + docDelete: vi.fn(() => ({ type: "None" })), + docMove: vi.fn(() => ({ type: "None" })), + docRename: vi.fn(() => ({ type: "None" })), + locationAddViaDialog: vi.fn(() => ({ type: "None" })), + locationRemove: vi.fn(() => ({ type: "None" })), + }), +); describe("useWorkspaceController", () => { beforeEach(() => { vi.clearAllMocks(); resetAppStore(); - useAppStore.getState().setLocations([{ id: 1, name: "Workspace", root_path: "/workspace", added_at: "2024-01-01" }]); + useAppStore.getState().setLocations([{ + id: 1, + name: "Workspace", + root_path: "/workspace", + added_at: "2024-01-01", + }]); }); it("ignores non-numeric locationId values in handleCreateNewDocument", () => { diff --git a/src/components/Sidebar/DocumentItem.tsx b/src/components/Sidebar/DocumentItem.tsx index 8c39cd3..867df72 100644 --- a/src/components/Sidebar/DocumentItem.tsx +++ b/src/components/Sidebar/DocumentItem.tsx @@ -1,11 +1,10 @@ -import { Button } from "$components/Button"; import { ContextMenu, ContextMenuDivider, ContextMenuItem, useContextMenu } from "$components/ContextMenu"; -import { Dialog } from "$components/Dialog"; import { ClipboardIcon, EditIcon, FileTextIcon, FolderIcon, TrashIcon } from "$icons"; import type { DocMeta } from "$types"; import { f } from "$utils/serialize"; import * as logger from "@tauri-apps/plugin-log"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { type DialogAnchor, OperationDialog } from "./OperationDialog"; import { TreeItem } from "./TreeItem"; const fileTextIcon = { Component: FileTextIcon, size: "sm" as const }; @@ -23,15 +22,18 @@ type DocumentItemProps = { }; function RenameDialog( - { isOpen, onClose, currentName, onRename }: { + { isOpen, onClose, currentName, onRename, anchor }: { isOpen: boolean; onClose: () => void; currentName: string; - onRename: (newName: string) => Promise; + onRename: (newName: string) => Promise; + anchor?: DialogAnchor; }, ) { const [name, setName] = useState(currentName); const [isRenaming, setIsRenaming] = useState(false); + const formId = "rename-document-form"; + const inputId = "rename-document-input"; const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); @@ -40,57 +42,70 @@ function RenameDialog( return; } setIsRenaming(true); - await onRename(name.trim()); - setIsRenaming(false); - onClose(); + try { + const renamed = await onRename(name.trim()); + if (renamed) { + onClose(); + } + } finally { + setIsRenaming(false); + } }, [name, currentName, onRename, onClose]); const handleInputChange = useCallback((e: React.ChangeEvent) => { setName(e.target.value); }, []); - useMemo(() => { + useEffect(() => { if (isOpen) { setName(currentName); } }, [isOpen, currentName]); return ( - -
-

Rename Document

+ + + -
- - -
-
+ ); } function MoveDialog( - { isOpen, onClose, currentPath, onMove }: { + { isOpen, onClose, currentPath, onMove, anchor }: { isOpen: boolean; onClose: () => void; currentPath: string; - onMove: (newPath: string) => Promise; + onMove: (newPath: string) => Promise; + anchor?: DialogAnchor; }, ) { const [path, setPath] = useState(currentPath); const [isMoving, setIsMoving] = useState(false); + const formId = "move-document-form"; + const inputId = "move-document-input"; const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); @@ -99,77 +114,100 @@ function MoveDialog( return; } setIsMoving(true); - await onMove(path.trim()); - setIsMoving(false); - onClose(); + try { + const moved = await onMove(path.trim()); + if (moved) { + onClose(); + } + } finally { + setIsMoving(false); + } }, [path, currentPath, onMove, onClose]); const handleInputChange = useCallback((e: React.ChangeEvent) => { setPath(e.target.value); }, []); - useMemo(() => { + useEffect(() => { if (isOpen) { setPath(currentPath); } }, [isOpen, currentPath]); return ( - -
-

Move Document

+ + +

Enter the new relative path for the document.

-
- - -
-
+ ); } function DeleteConfirmDialog( - { isOpen, onClose, documentName, onDelete }: { + { isOpen, onClose, documentName, onDelete, anchor }: { isOpen: boolean; onClose: () => void; documentName: string; - onDelete: () => Promise; + onDelete: () => Promise; + anchor?: DialogAnchor; }, ) { const [isDeleting, setIsDeleting] = useState(false); const handleDelete = useCallback(async () => { setIsDeleting(true); - await onDelete(); - setIsDeleting(false); - onClose(); + try { + const deleted = await onDelete(); + if (deleted) { + onClose(); + } + } finally { + setIsDeleting(false); + } }, [onDelete, onClose]); return ( - -
-

Delete Document

-

- Are you sure you want to delete{" "} - {documentName}? This action cannot be undone. -

-
- - -
-
-
+ +

+ Are you sure you want to delete{" "} + {documentName}? This action cannot be undone. +

+
); } @@ -190,6 +228,7 @@ export function DocumentItem( const [showRenameDialog, setShowRenameDialog] = useState(false); const [showMoveDialog, setShowMoveDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [operationAnchor, setOperationAnchor] = useState(); const displayLabel = useMemo(() => { if (filenameVisibility) { @@ -219,35 +258,49 @@ export function DocumentItem( }, [doc.rel_path]); const handleRename = useCallback(() => { + setOperationAnchor({ x: position.x, y: position.y }); close(); setShowRenameDialog(true); - }, [close]); + }, [close, position.x, position.y]); const handleMove = useCallback(() => { + setOperationAnchor({ x: position.x, y: position.y }); close(); setShowMoveDialog(true); - }, [close]); + }, [close, position.x, position.y]); const handleDeleteClick = useCallback(() => { + setOperationAnchor({ x: position.x, y: position.y }); close(); setShowDeleteDialog(true); - }, [close]); + }, [close, position.x, position.y]); - const performRename = useCallback(async (newName: string) => { - await onRenameDocument(id, doc.rel_path, newName); + const performRename = useCallback((newName: string) => { + return onRenameDocument(id, doc.rel_path, newName); }, [id, doc.rel_path, onRenameDocument]); - const performMove = useCallback(async (newRelPath: string) => { - await onMoveDocument(id, doc.rel_path, newRelPath); + const performMove = useCallback((newRelPath: string) => { + return onMoveDocument(id, doc.rel_path, newRelPath); }, [id, doc.rel_path, onMoveDocument]); - const performDelete = useCallback(async () => { - await onDeleteDocument(id, doc.rel_path); + const performDelete = useCallback(() => { + return onDeleteDocument(id, doc.rel_path); }, [id, doc.rel_path, onDeleteDocument]); - const closeRenameDialog = useCallback(() => setShowRenameDialog(false), []); - const closeMoveDialog = useCallback(() => setShowMoveDialog(false), []); - const closeDeleteDialog = useCallback(() => setShowDeleteDialog(false), []); + const closeRenameDialog = useCallback(() => { + setShowRenameDialog(false); + setOperationAnchor(undefined); + }, []); + + const closeMoveDialog = useCallback(() => { + setShowMoveDialog(false); + setOperationAnchor(undefined); + }, []); + + const closeDeleteDialog = useCallback(() => { + setShowDeleteDialog(false); + setOperationAnchor(undefined); + }, []); const contextMenuItems = useMemo<(ContextMenuItem | ContextMenuDivider)[]>( () => [ @@ -279,13 +332,20 @@ export function DocumentItem( isOpen={showRenameDialog} onClose={closeRenameDialog} currentName={currentFilename} - onRename={performRename} /> - + onRename={performRename} + anchor={operationAnchor} /> + + onDelete={performDelete} + anchor={operationAnchor} /> ); } diff --git a/src/components/Sidebar/OperationDialog.tsx b/src/components/Sidebar/OperationDialog.tsx new file mode 100644 index 0000000..c118615 --- /dev/null +++ b/src/components/Sidebar/OperationDialog.tsx @@ -0,0 +1,274 @@ +import { Button } from "$components/Button"; +import { Dialog } from "$components/Dialog"; +import { XIcon } from "$icons"; +import { clamp } from "$utils/math"; +import { cn } from "$utils/tw"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import type { CSSProperties, ReactNode } from "react"; + +type DialogAnchor = { x: number; y: number }; + +type OperationDialogProps = { + isOpen: boolean; + onClose: () => void; + ariaLabel: string; + title: string; + description?: ReactNode; + anchor?: DialogAnchor; + children?: ReactNode; + confirmLabel: string; + pendingLabel?: string; + cancelLabel?: string; + confirmButtonType?: "button" | "submit"; + confirmFormId?: string; + confirmDisabled?: boolean; + isPending?: boolean; + onConfirm?: () => void; + tone?: "default" | "danger"; + widthClassName?: string; +}; + +type OperationHeaderProps = { title: string; description?: ReactNode; onClose: () => void; isPending: boolean }; + +type OperationFooterProps = { + cancelLabel: string; + onClose: () => void; + isPending: boolean; + confirmButtonType: "button" | "submit"; + confirmFormId?: string; + onConfirm?: () => void; + tone: "default" | "danger"; + confirmClassName: string; + confirmDisabled: boolean; + confirmText: string; +}; + +const VIEWPORT_GUTTER_PX = 10; +const ANCHOR_OFFSET_PX = 12; + +function getPinnedPosition( + panelWidth: number, + panelHeight: number, + viewportWidth: number, + viewportHeight: number, + anchor?: DialogAnchor, +) { + if (!anchor) { + return { + left: Math.max(VIEWPORT_GUTTER_PX, (viewportWidth - panelWidth) / 2), + top: Math.max(VIEWPORT_GUTTER_PX, (viewportHeight - panelHeight) / 2), + }; + } + + const rightSideLeft = anchor.x + ANCHOR_OFFSET_PX; + const leftSideLeft = anchor.x - panelWidth - ANCHOR_OFFSET_PX; + const bottomTop = anchor.y + ANCHOR_OFFSET_PX; + const topTop = anchor.y - panelHeight - ANCHOR_OFFSET_PX; + + const left = rightSideLeft + panelWidth <= viewportWidth - VIEWPORT_GUTTER_PX + ? rightSideLeft + : (leftSideLeft >= VIEWPORT_GUTTER_PX + ? leftSideLeft + : clamp(rightSideLeft, VIEWPORT_GUTTER_PX, viewportWidth - panelWidth - VIEWPORT_GUTTER_PX)); + + const top = bottomTop + panelHeight <= viewportHeight - VIEWPORT_GUTTER_PX + ? bottomTop + : (topTop >= VIEWPORT_GUTTER_PX + ? topTop + : clamp(bottomTop, VIEWPORT_GUTTER_PX, viewportHeight - panelHeight - VIEWPORT_GUTTER_PX)); + + return { left, top }; +} + +function OperationDialogHeader({ title, description, onClose, isPending }: OperationHeaderProps) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ +
+ ); +} + +function OperationDialogFooter( + { + cancelLabel, + onClose, + isPending, + confirmButtonType, + confirmFormId, + onConfirm, + tone, + confirmClassName, + confirmDisabled, + confirmText, + }: OperationFooterProps, +) { + return ( +
+ + +
+ ); +} + +export function OperationDialog( + { + isOpen, + onClose, + ariaLabel, + title, + description, + anchor, + children, + confirmLabel, + pendingLabel, + cancelLabel = "Cancel", + confirmButtonType = "button", + confirmFormId, + confirmDisabled = false, + isPending = false, + onConfirm, + tone = "default", + widthClassName = "w-[min(92vw,380px)]", + }: OperationDialogProps, +) { + const panelRef = useRef(null); + const [panelStyle, setPanelStyle] = useState({}); + const [isPositioned, setIsPositioned] = useState(false); + + const handleRequestClose = useCallback(() => { + if (!isPending) { + onClose(); + } + }, [isPending, onClose]); + + const positionPanel = useCallback(() => { + const panel = panelRef.current; + if (!panel || typeof globalThis.innerWidth !== "number") { + return; + } + + const rect = panel.getBoundingClientRect(); + const position = getPinnedPosition(rect.width, rect.height, globalThis.innerWidth, globalThis.innerHeight, anchor); + setPanelStyle({ left: `${position.left}px`, top: `${position.top}px` }); + setIsPositioned(true); + }, [anchor]); + + useLayoutEffect(() => { + if (!isOpen) { + return; + } + + setIsPositioned(false); + const frame = globalThis.requestAnimationFrame(positionPanel); + const handleResize = () => positionPanel(); + globalThis.addEventListener("resize", handleResize); + + return () => { + globalThis.cancelAnimationFrame(frame); + globalThis.removeEventListener("resize", handleResize); + }; + }, [isOpen, positionPanel]); + + useEffect(() => { + if (!isOpen) { + return; + } + + const panel = panelRef.current; + if (!panel || typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(positionPanel); + observer.observe(panel); + return () => observer.disconnect(); + }, [isOpen, positionPanel]); + + useEffect(() => { + if (!isOpen || typeof document.addEventListener !== "function") { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + if (isPending) { + return; + } + + const panel = panelRef.current; + if (!panel) { + return; + } + + if (event.target instanceof Node && !panel.contains(event.target)) { + onClose(); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [isOpen, isPending, onClose]); + + const confirmText = isPending ? (pendingLabel ?? confirmLabel) : confirmLabel; + const confirmClassName = tone === "danger" + ? "border-support-error text-support-error hover:bg-support-error hover:text-white" + : ""; + + return ( + +
+ +
{children}
+ +
+
+ ); +} + +export type { DialogAnchor, OperationDialogProps }; diff --git a/src/utils/math.ts b/src/utils/math.ts new file mode 100644 index 0000000..44f88f3 --- /dev/null +++ b/src/utils/math.ts @@ -0,0 +1,3 @@ +export function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +}