From 194ceb6414fb0e3d1fb5173f8f8867984500db70 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 24 Feb 2026 15:39:52 -0600 Subject: [PATCH] refactor: responsiveness --- docs/roadmap.md | 21 - src/App.tsx | 45 +- src/__tests__/WorkspacePanel.test.tsx | 408 ++++++++---------- src/__tests__/useViewportTier.test.ts | 24 ++ src/components/Dialog.tsx | 110 +++++ src/components/DocumentTabs/DocumentTab.tsx | 20 +- src/components/DocumentTabs/DocumentTabs.tsx | 14 +- src/components/SearchPanel.tsx | 104 +++-- src/components/StatusBar.tsx | 147 ++++--- src/components/Toolbar.tsx | 52 ++- src/components/layout/AppHeaderBar.tsx | 50 ++- src/components/layout/BackendAlerts.tsx | 71 ++- .../LayoutSettingsPanel.tsx | 65 +-- src/components/layout/SearchOverlay.tsx | 8 +- src/components/layout/WorkspacePanel.tsx | 51 ++- .../pdf/ExportDialog/ExportDialog.tsx | 65 +-- .../pdf/ExportDialog/ExportFooter.tsx | 6 +- src/hooks/useViewportTier.ts | 65 +++ src/state/panel-selectors.ts | 32 +- src/state/types.ts | 13 + src/utils/date.ts | 19 + 21 files changed, 883 insertions(+), 507 deletions(-) create mode 100644 src/__tests__/useViewportTier.test.ts create mode 100644 src/components/Dialog.tsx create mode 100644 src/hooks/useViewportTier.ts diff --git a/docs/roadmap.md b/docs/roadmap.md index 0545610..6fed326 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -62,24 +62,3 @@ Improve file organization 2. **Recovery** - Corrupt settings/workspace → app resets safely - Missing location root → UI prompts to relink/remove - -## Responsiveness adjustments - -Improve responsiveness across desktop widths and small-window usage while keeping editor workflows stable. - -### Tasks - -- **Baseline and guardrails** - - Confirm hotspot map: top bars, fixed overlays/dialogs, status/tabs density, and split/sidebar constraints. - - Define supported width tiers and expected behavior per tier. - - Add a manual viewport checklist for core flows (open doc, edit, search, export, quick capture). -- **Core shell responsiveness** - - Add narrow-width compaction for `AppHeaderBar` and `Toolbar` (wrapping/collapsing with clear priority rules). - - Tune sidebar and split-pane constraints with graceful fallback when split is not feasible. - - Use react motion for smooth transitions and animations. - - Introduce predictable truncation and information priority for tabs and status bar. -- Create Dialog primitive that can be used for all overlays and dialogs & modals - - Convert layout/search overlays from fixed desktop assumptions to responsive sheet/panel behavior. - - Rework backend alert anchoring/stacking to avoid clipping at narrow widths. - - Improve PDF export dialog sizing/scroll behavior for constrained viewports. -- Document responsiveness rules so future UI changes follow the same standards. diff --git a/src/App.tsx b/src/App.tsx index 504eb97..fd47571 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -76,9 +76,8 @@ function App() { const [isLayoutSettingsOpen, setIsLayoutSettingsOpen] = useState(false); const [isPdfExportDialogOpen, setIsPdfExportDialogOpen] = useState(false); const [layoutSettingsHydrated, setLayoutSettingsHydrated] = useState(false); - const [globalCaptureSettings, setGlobalCaptureSettings] = useState( - DEFAULT_GLOBAL_CAPTURE_SETTINGS, - ); + const [globalCaptureSettings, setGlobalCaptureSettings] = useState(DEFAULT_GLOBAL_CAPTURE_SETTINGS); + const cursorPosition = useMemo(() => pick(editorModel, ["cursorLine", "cursorColumn"]), [editorModel]); useWorkspaceSync(); useLayoutHotkeys(); @@ -120,6 +119,13 @@ function App() { }; }, [editorModel.selectionFrom, editorModel.selectionTo, editorModel.text]); + const editorStats = useMemo(() => ({ ...cursorPosition, wordCount, charCount, selectionCount }), [ + cursorPosition, + wordCount, + charCount, + selectionCount, + ]); + const activeDocMeta = useMemo(() => { if (!activeTab) { return null; @@ -259,13 +265,7 @@ function App() { [editorModel.text, handleEditorChangeWithTyping, handleSave, handleCursorMove, handleSelectionChange], ); - const statusBarProps = useMemo( - () => ({ - docMeta: activeDocMeta, - stats: { ...pick(editorModel, ["cursorLine", "cursorColumn"]), wordCount, charCount, selectionCount }, - }), - [activeDocMeta, editorModel, wordCount, charCount, selectionCount], - ); + const statusBarProps = useMemo(() => ({ docMeta: activeDocMeta, stats: editorStats }), [activeDocMeta, editorStats]); const previewProps = useMemo( () => ({ @@ -313,8 +313,6 @@ function App() { }, []); const focusModePanelProps = useMemo(() => { - const pos = pick(editorModel, ["cursorLine", "cursorColumn"]); - const stats = { ...pos, wordCount, charCount, selectionCount }; return ({ editor: { initialText: editorModel.text, @@ -323,14 +321,12 @@ function App() { onCursorMove: handleCursorMove, onSelectionChange: handleSelectionChange, }, - statusBar: { docMeta: activeDocMeta, stats }, + statusBar: { docMeta: activeDocMeta, stats: editorStats }, }); }, [ activeDocMeta, - editorModel, - wordCount, - charCount, - selectionCount, + editorStats, + editorModel.text, handleEditorChange, handleSave, handleCursorMove, @@ -479,21 +475,6 @@ function App() { [sidebarProps, toolbarProps, tabProps, editorProps, previewProps, statusBarProps, calmUiEffectiveVisibility], ); - useEffect(() => { - if (!isLayoutSettingsOpen) { - return; - } - - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setIsLayoutSettingsOpen(false); - } - }; - - globalThis.addEventListener("keydown", closeOnEscape); - return () => globalThis.removeEventListener("keydown", closeOnEscape); - }, [isLayoutSettingsOpen]); - if (isFocusMode) { return ; } diff --git a/src/__tests__/WorkspacePanel.test.tsx b/src/__tests__/WorkspacePanel.test.tsx index 3b17a9a..4760ae7 100644 --- a/src/__tests__/WorkspacePanel.test.tsx +++ b/src/__tests__/WorkspacePanel.test.tsx @@ -1,5 +1,10 @@ /* oxlint-disable eslint-plugin-react-perf/jsx-no-new-object-as-prop */ +import { DocumentTabsProps } from "$components/DocumentTabs"; +import { EditorProps } from "$components/Editor"; import { WorkspacePanel } from "$components/layout/WorkspacePanel"; +import type { CalmUiVisibility, WorkspacePanelProps } from "$components/layout/WorkspacePanel"; +import { PreviewProps } from "$components/Preview"; +import { StatusBarProps } from "$components/StatusBar"; import { useEditorPresentationState, useSidebarState, @@ -9,8 +14,17 @@ import { useWorkspacePanelStatusBarCollapsed, useWorkspacePanelTopBarsCollapsed, } from "$state/panel-selectors"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import type { + EditorPresentationStateReturn, + SidebarStateReturn, + StatusBarCollapsedReturn, + ToolbarStateReturn, + TopBarsCollapsedReturn, + WorkspacePanelModeStateReturn, + WorkspacePanelSidebarStateReturn, +} from "$state/panel-selectors"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock( "$state/panel-selectors", @@ -25,83 +39,149 @@ vi.mock( }), ); +type SelectorOverrides = { + sidebarState?: Partial; + toolbarState?: Partial; + editorPresentationState?: Partial; + workspacePanelSidebarState?: Partial; + workspacePanelModeState?: Partial; + topBarsCollapsed?: TopBarsCollapsedReturn; + statusBarCollapsed?: StatusBarCollapsedReturn; +}; + +type WorkspacePanelPropOverrides = { + sidebar?: Partial; + toolbar?: Partial; + tabs?: Partial; + editor?: Partial; + preview?: Partial; + statusBar?: Partial; + calmUiVisibility?: CalmUiVisibility; +}; + +const createSidebarState = (overrides: Partial = {}): SidebarStateReturn => ({ + locations: [], + selectedLocationId: undefined, + selectedDocPath: undefined, + documents: [], + isLoading: false, + filterText: "", + setFilterText: vi.fn(), + selectLocation: vi.fn(), + toggleSidebarCollapsed: vi.fn(), + ...overrides, +}); + +const createToolbarState = (overrides: Partial = {}): ToolbarStateReturn => ({ + isSplitView: false, + isFocusMode: false, + isPreviewVisible: false, + toggleSplitView: vi.fn(), + toggleFocusMode: vi.fn(), + togglePreviewVisible: vi.fn(), + ...overrides, +}); + +const createEditorPresentationState = ( + overrides: Partial = {}, +): EditorPresentationStateReturn => ({ + theme: "dark", + showLineNumbers: true, + textWrappingEnabled: true, + syntaxHighlightingEnabled: true, + fontSize: 16, + fontFamily: "IBM Plex Mono", + typewriterScrollingEnabled: false, + focusDimmingMode: "off", + posHighlightingEnabled: false, + styleCheckSettings: { + enabled: false, + categories: { filler: true, redundancy: true, cliche: true }, + customPatterns: [], + }, + ...overrides, +}); + +const createWorkspacePanelSidebarState = ( + overrides: Partial = {}, +): WorkspacePanelSidebarStateReturn => ({ sidebarCollapsed: true, ...overrides }); + +const createWorkspacePanelModeState = ( + overrides: Partial = {}, +): WorkspacePanelModeStateReturn => ({ isSplitView: false, isPreviewVisible: false, ...overrides }); + +const mockPanelSelectors = (overrides: SelectorOverrides = {}): void => { + vi.mocked(useSidebarState).mockReturnValue(createSidebarState(overrides.sidebarState)); + vi.mocked(useToolbarState).mockReturnValue(createToolbarState(overrides.toolbarState)); + vi.mocked(useEditorPresentationState).mockReturnValue( + createEditorPresentationState(overrides.editorPresentationState), + ); + vi.mocked(useWorkspacePanelSidebarState).mockReturnValue( + createWorkspacePanelSidebarState(overrides.workspacePanelSidebarState), + ); + vi.mocked(useWorkspacePanelModeState).mockReturnValue( + createWorkspacePanelModeState(overrides.workspacePanelModeState), + ); + vi.mocked(useWorkspacePanelTopBarsCollapsed).mockReturnValue(overrides.topBarsCollapsed ?? true); + vi.mocked(useWorkspacePanelStatusBarCollapsed).mockReturnValue(overrides.statusBarCollapsed ?? true); +}; + +const createWorkspacePanelProps = (overrides: WorkspacePanelPropOverrides = {}): WorkspacePanelProps => ({ + sidebar: { + handleAddLocation: vi.fn(), + handleRemoveLocation: vi.fn(), + handleSelectDocument: vi.fn(), + ...overrides.sidebar, + }, + toolbar: { saveStatus: "Idle", onSave: vi.fn(), onOpenSettings: vi.fn(), ...overrides.toolbar }, + tabs: { + tabs: [], + activeTabId: null, + handleSelectTab: vi.fn(), + handleCloseTab: vi.fn(), + handleReorderTabs: vi.fn(), + ...overrides.tabs, + }, + editor: { + initialText: "# Document", + onChange: vi.fn(), + onSave: vi.fn(), + onCursorMove: vi.fn(), + onSelectionChange: vi.fn(), + ...overrides.editor, + }, + preview: { + renderResult: { + html: "

Preview content

", + metadata: { title: null, outline: [], links: [], task_items: { total: 0, completed: 0 }, word_count: 2 }, + }, + theme: "dark", + editorLine: 1, + onScrollToLine: vi.fn(), + ...overrides.preview, + }, + statusBar: { stats: { cursorLine: 1, cursorColumn: 1, wordCount: 0, charCount: 0 }, ...overrides.statusBar }, + calmUiVisibility: overrides.calmUiVisibility, +}); + +const renderWorkspacePanel = ( + propOverrides: WorkspacePanelPropOverrides = {}, + selectorOverrides: SelectorOverrides = {}, +) => { + mockPanelSelectors(selectorOverrides); + return render(); +}; + describe("WorkspacePanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders preview-only mode when preview is enabled without split view", () => { - vi.mocked(useSidebarState).mockReturnValue({ - locations: [], - selectedLocationId: undefined, - selectedDocPath: undefined, - documents: [], - isLoading: false, - filterText: "", - setFilterText: vi.fn(), - selectLocation: vi.fn(), - toggleSidebarCollapsed: vi.fn(), - }); - vi.mocked(useToolbarState).mockReturnValue({ - isSplitView: false, - isFocusMode: false, - isPreviewVisible: true, - toggleSplitView: vi.fn(), - toggleFocusMode: vi.fn(), - togglePreviewVisible: vi.fn(), + const { container } = renderWorkspacePanel({ editor: { initialText: "# Hidden" } }, { + toolbarState: { isPreviewVisible: true }, + workspacePanelModeState: { isPreviewVisible: true }, }); - vi.mocked(useEditorPresentationState).mockReturnValue({ - theme: "dark", - showLineNumbers: true, - textWrappingEnabled: true, - syntaxHighlightingEnabled: true, - fontSize: 16, - fontFamily: "IBM Plex Mono", - typewriterScrollingEnabled: false, - focusDimmingMode: "off", - posHighlightingEnabled: false, - styleCheckSettings: { - enabled: false, - categories: { filler: true, redundancy: true, cliche: true }, - customPatterns: [], - }, - }); - vi.mocked(useWorkspacePanelSidebarState).mockReturnValue({ sidebarCollapsed: true }); - vi.mocked(useWorkspacePanelModeState).mockReturnValue({ isSplitView: false, isPreviewVisible: true }); - vi.mocked(useWorkspacePanelTopBarsCollapsed).mockReturnValue(true); - vi.mocked(useWorkspacePanelStatusBarCollapsed).mockReturnValue(true); - const sidebar = { handleAddLocation: vi.fn(), handleRemoveLocation: vi.fn(), handleSelectDocument: vi.fn() }; - const toolbar = { saveStatus: "Idle" as const, onSave: vi.fn(), onOpenSettings: vi.fn() }; - const tabs = { - tabs: [], - activeTabId: null, - handleSelectTab: vi.fn(), - handleCloseTab: vi.fn(), - handleReorderTabs: vi.fn(), - }; - const editor = { - initialText: "# Hidden", - onChange: vi.fn(), - onSave: vi.fn(), - onCursorMove: vi.fn(), - onSelectionChange: vi.fn(), - }; - const preview = { - renderResult: { - html: "

Preview content

", - metadata: { title: null, outline: [], links: [], task_items: { total: 0, completed: 0 }, word_count: 2 }, - }, - theme: "dark" as const, - editorLine: 1, - onScrollToLine: vi.fn(), - }; - const statusBar = { stats: { cursorLine: 1, cursorColumn: 1, wordCount: 0, charCount: 0 } }; - - const { container } = render( - , - ); expect(screen.getByText("Preview content")).toBeInTheDocument(); expect(container.querySelector("[data-testid='editor-container']")).not.toBeInTheDocument(); @@ -109,81 +189,11 @@ describe("WorkspacePanel", () => { it("renders sidebar controls and supports resizing", () => { const onToggleSidebar = vi.fn(); - vi.mocked(useSidebarState).mockReturnValue({ - locations: [], - selectedLocationId: undefined, - selectedDocPath: undefined, - documents: [], - isLoading: false, - filterText: "", - setFilterText: vi.fn(), - selectLocation: vi.fn(), - toggleSidebarCollapsed: onToggleSidebar, - }); - vi.mocked(useToolbarState).mockReturnValue({ - isSplitView: false, - isFocusMode: false, - isPreviewVisible: false, - toggleSplitView: vi.fn(), - toggleFocusMode: vi.fn(), - togglePreviewVisible: vi.fn(), - }); - vi.mocked(useEditorPresentationState).mockReturnValue({ - theme: "dark", - showLineNumbers: true, - textWrappingEnabled: true, - syntaxHighlightingEnabled: true, - fontSize: 16, - fontFamily: "IBM Plex Mono", - typewriterScrollingEnabled: false, - focusDimmingMode: "off", - posHighlightingEnabled: false, - styleCheckSettings: { - enabled: false, - categories: { filler: true, redundancy: true, cliche: true }, - customPatterns: [], - }, - }); - vi.mocked(useWorkspacePanelSidebarState).mockReturnValue({ sidebarCollapsed: false }); - vi.mocked(useWorkspacePanelModeState).mockReturnValue({ isSplitView: false, isPreviewVisible: false }); - vi.mocked(useWorkspacePanelTopBarsCollapsed).mockReturnValue(true); - vi.mocked(useWorkspacePanelStatusBarCollapsed).mockReturnValue(true); - const sidebar = { handleAddLocation: vi.fn(), handleRemoveLocation: vi.fn(), handleSelectDocument: vi.fn() }; - const toolbar = { saveStatus: "Idle" as const, onSave: vi.fn(), onOpenSettings: vi.fn() }; - const tabs = { - tabs: [], - activeTabId: null, - handleSelectTab: vi.fn(), - handleCloseTab: vi.fn(), - handleReorderTabs: vi.fn(), - }; - const editor = { - initialText: "# Visible", - onChange: vi.fn(), - onSave: vi.fn(), - onCursorMove: vi.fn(), - onSelectionChange: vi.fn(), - }; - const preview = { - renderResult: { - html: "

Preview content

", - metadata: { title: null, outline: [], links: [], task_items: { total: 0, completed: 0 }, word_count: 2 }, - }, - theme: "dark" as const, - editorLine: 1, - onScrollToLine: vi.fn(), - }; - const statusBar = { stats: { cursorLine: 1, cursorColumn: 1, wordCount: 0, charCount: 0 } }; - render( - , - ); + renderWorkspacePanel({ editor: { initialText: "# Visible" } }, { + sidebarState: { toggleSidebarCollapsed: onToggleSidebar }, + workspacePanelSidebarState: { sidebarCollapsed: false }, + }); fireEvent.click(screen.getByTitle("Hide sidebar (Ctrl+B)")); expect(onToggleSidebar).toHaveBeenCalledOnce(); @@ -201,81 +211,10 @@ describe("WorkspacePanel", () => { }); it("renders split mode and supports resizing between editor and preview", () => { - vi.mocked(useSidebarState).mockReturnValue({ - locations: [], - selectedLocationId: undefined, - selectedDocPath: undefined, - documents: [], - isLoading: false, - filterText: "", - setFilterText: vi.fn(), - selectLocation: vi.fn(), - toggleSidebarCollapsed: vi.fn(), - }); - vi.mocked(useToolbarState).mockReturnValue({ - isSplitView: true, - isFocusMode: false, - isPreviewVisible: true, - toggleSplitView: vi.fn(), - toggleFocusMode: vi.fn(), - togglePreviewVisible: vi.fn(), + renderWorkspacePanel({ editor: { initialText: "# Split" } }, { + toolbarState: { isSplitView: true, isPreviewVisible: true }, + workspacePanelModeState: { isSplitView: true, isPreviewVisible: true }, }); - vi.mocked(useEditorPresentationState).mockReturnValue({ - theme: "dark", - showLineNumbers: true, - textWrappingEnabled: true, - syntaxHighlightingEnabled: true, - fontSize: 16, - fontFamily: "IBM Plex Mono", - typewriterScrollingEnabled: false, - focusDimmingMode: "off", - posHighlightingEnabled: false, - styleCheckSettings: { - enabled: false, - categories: { filler: true, redundancy: true, cliche: true }, - customPatterns: [], - }, - }); - vi.mocked(useWorkspacePanelSidebarState).mockReturnValue({ sidebarCollapsed: true }); - vi.mocked(useWorkspacePanelModeState).mockReturnValue({ isSplitView: true, isPreviewVisible: true }); - vi.mocked(useWorkspacePanelTopBarsCollapsed).mockReturnValue(true); - vi.mocked(useWorkspacePanelStatusBarCollapsed).mockReturnValue(true); - const sidebar = { handleAddLocation: vi.fn(), handleRemoveLocation: vi.fn(), handleSelectDocument: vi.fn() }; - const toolbar = { saveStatus: "Idle" as const, onSave: vi.fn(), onOpenSettings: vi.fn() }; - const tabs = { - tabs: [], - activeTabId: null, - handleSelectTab: vi.fn(), - handleCloseTab: vi.fn(), - handleReorderTabs: vi.fn(), - }; - const editor = { - initialText: "# Split", - onChange: vi.fn(), - onSave: vi.fn(), - onCursorMove: vi.fn(), - onSelectionChange: vi.fn(), - }; - const preview = { - renderResult: { - html: "

Preview content

", - metadata: { title: null, outline: [], links: [], task_items: { total: 0, completed: 0 }, word_count: 2 }, - }, - theme: "dark" as const, - editorLine: 1, - onScrollToLine: vi.fn(), - }; - const statusBar = { stats: { cursorLine: 1, cursorColumn: 1, wordCount: 0, charCount: 0 } }; - - render( - , - ); const separator = screen.getByRole("separator", { name: "Resize split panes" }); const editorPane = screen.getByTestId("editor-container").parentElement as HTMLElement; @@ -288,4 +227,25 @@ describe("WorkspacePanel", () => { expect(editorPane).toHaveStyle({ width: `${initialWidth + 120}px` }); }); + + it("falls back from split mode to editor-only when viewport is too narrow", () => { + Object.defineProperty(globalThis, "innerWidth", { configurable: true, writable: true, value: 540 }); + act(() => { + globalThis.dispatchEvent(new Event("resize")); + }); + + const { container } = renderWorkspacePanel({ editor: { initialText: "# Split fallback" } }, { + toolbarState: { isSplitView: true, isPreviewVisible: true }, + workspacePanelModeState: { isSplitView: true, isPreviewVisible: true }, + }); + + expect(container.querySelector("[aria-label='Resize split panes']")).not.toBeInTheDocument(); + expect(screen.queryByText("Preview content")).not.toBeInTheDocument(); + expect(container.querySelector("[data-testid='editor-container']")).toBeInTheDocument(); + + Object.defineProperty(globalThis, "innerWidth", { configurable: true, writable: true, value: 1024 }); + act(() => { + globalThis.dispatchEvent(new Event("resize")); + }); + }); }); diff --git a/src/__tests__/useViewportTier.test.ts b/src/__tests__/useViewportTier.test.ts new file mode 100644 index 0000000..59c7259 --- /dev/null +++ b/src/__tests__/useViewportTier.test.ts @@ -0,0 +1,24 @@ +import { getViewportTier, VIEWPORT_BREAKPOINTS } from "$hooks/useViewportTier"; +import { describe, expect, it } from "vitest"; + +describe("getViewportTier", () => { + it("returns compact at or below compact max", () => { + expect(getViewportTier(320)).toBe("compact"); + expect(getViewportTier(VIEWPORT_BREAKPOINTS.compactMax)).toBe("compact"); + }); + + it("returns narrow between compact and narrow max", () => { + expect(getViewportTier(VIEWPORT_BREAKPOINTS.compactMax + 1)).toBe("narrow"); + expect(getViewportTier(VIEWPORT_BREAKPOINTS.narrowMax)).toBe("narrow"); + }); + + it("returns standard between narrow and standard max", () => { + expect(getViewportTier(VIEWPORT_BREAKPOINTS.narrowMax + 1)).toBe("standard"); + expect(getViewportTier(VIEWPORT_BREAKPOINTS.standardMax)).toBe("standard"); + }); + + it("returns wide above standard max", () => { + expect(getViewportTier(VIEWPORT_BREAKPOINTS.standardMax + 1)).toBe("wide"); + expect(getViewportTier(2200)).toBe("wide"); + }); +}); diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx new file mode 100644 index 0000000..60f72f0 --- /dev/null +++ b/src/components/Dialog.tsx @@ -0,0 +1,110 @@ +import { cn } from "$utils/tw"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect } from "react"; +import type { CSSProperties, ReactNode } from "react"; + +export type DialogMotionPreset = "scale" | "slideUp" | "slideRight"; + +type DialogProps = { + isOpen: boolean; + children: ReactNode; + onClose?: () => void; + ariaLabel?: string; + showBackdrop?: boolean; + closeOnBackdrop?: boolean; + backdropClassName?: string; + containerClassName?: string; + containerStyle?: CSSProperties; + panelClassName?: string; + panelStyle?: CSSProperties; + motionPreset?: DialogMotionPreset; +}; + +const DIALOG_MOTION_PRESETS: Record< + DialogMotionPreset, + { initial: Record; animate: Record; exit: Record } +> = { + scale: { + initial: { opacity: 0, scale: 0.97, y: 8 }, + animate: { opacity: 1, scale: 1, y: 0 }, + exit: { opacity: 0, scale: 0.97, y: 6 }, + }, + slideUp: { initial: { opacity: 0, y: 24 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: 18 } }, + slideRight: { initial: { opacity: 0, x: 18 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: 12 } }, +}; + +const BACKDROP_FADE_MOTION = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } as const; +const BACKDROP_FADE_TRANSITION = { duration: 0.16, ease: "easeOut" } as const; +const DIALOG_SURFACE_TRANSITION = { duration: 0.2, ease: "easeOut" } as const; + +export function Dialog( + { + isOpen, + children, + onClose, + ariaLabel, + showBackdrop = true, + closeOnBackdrop = true, + backdropClassName, + containerClassName, + containerStyle, + panelClassName, + panelStyle, + motionPreset = "scale", + }: DialogProps, +) { + const motionConfig = DIALOG_MOTION_PRESETS[motionPreset]; + + const handleBackdropClick = closeOnBackdrop ? onClose : undefined; + + useEffect(() => { + if (!isOpen || !onClose || typeof globalThis.addEventListener !== "function") { + return; + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + globalThis.addEventListener("keydown", handleEscape); + return () => globalThis.removeEventListener("keydown", handleEscape); + }, [isOpen, onClose]); + + return ( + + {isOpen && ( +
+ {showBackdrop && ( + + )} + + + {children} + +
+ )} +
+ ); +} diff --git a/src/components/DocumentTabs/DocumentTab.tsx b/src/components/DocumentTabs/DocumentTab.tsx index 0c287b7..5400744 100644 --- a/src/components/DocumentTabs/DocumentTab.tsx +++ b/src/components/DocumentTabs/DocumentTab.tsx @@ -14,6 +14,7 @@ type DocumentTabProps = { handleContextMenu: (e: React.MouseEvent, tabId: string) => void; onSelectTab: (tabId: string) => void; onCloseTab: (tabId: string) => void; + compact?: boolean; }; export const DocumentTab = ( @@ -28,10 +29,11 @@ export const DocumentTab = ( handleContextMenu, onSelectTab, onCloseTab, + compact = false, }: DocumentTabProps, ) => { - const isActive = tab.id === activeTabId; - const isDragOver = tab.id === dragOverTab; + const isActive = useMemo(() => tab.id === activeTabId, [tab.id, activeTabId]); + const isDragOver = useMemo(() => tab.id === dragOverTab, [tab.id, dragOverTab]); const onDragStart: DragEventHandler = useCallback((e) => { handleDragStart(e, tab.id); }, [tab.id, handleDragStart]); @@ -71,9 +73,15 @@ export const DocumentTab = ( const classes = useMemo(() => { const base = [ - "group flex items-center gap-1.5 px-3 min-w-[120px] max-w-[200px] shrink-0 cursor-pointer border-r border-border-subtle select-none transition-all duration-150", + "group flex items-center gap-1.5 px-2.5 shrink-0 cursor-pointer border-r border-border-subtle select-none transition-all duration-150", ]; + if (compact) { + base.push("min-w-[96px] max-w-[152px]"); + } else { + base.push("min-w-[120px] max-w-[220px]"); + } + if (isActive) { base.push("bg-layer-01 border-b-2 border-b-accent-blue"); } @@ -90,10 +98,10 @@ export const DocumentTab = ( } return base.join(" "); - }, [isActive, isDragOver, draggingTab, tab.id]); + }, [compact, isActive, isDragOver, draggingTab, tab.id]); const titleClasses = useMemo(() => { - const base = ["flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[0.8125rem]"]; + const base = ["min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[0.8125rem]"]; if (isActive) { base.push("text-text-primary font-medium"); @@ -122,7 +130,7 @@ export const DocumentTab = ( onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}> {tab.isModified && } - + {compact ? null : } {tab.title} diff --git a/src/components/DocumentTabs/DocumentTabs.tsx b/src/components/DocumentTabs/DocumentTabs.tsx index 42ec713..abd78bd 100644 --- a/src/components/DocumentTabs/DocumentTabs.tsx +++ b/src/components/DocumentTabs/DocumentTabs.tsx @@ -1,4 +1,5 @@ import { Button } from "$components/Button"; +import { useViewportTier } from "$hooks/useViewportTier"; import type { Tab } from "$types"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DocumentTab } from "./DocumentTab"; @@ -14,6 +15,7 @@ export type DocumentTabsProps = { export function DocumentTabs( { tabs, activeTabId, handleSelectTab, handleCloseTab, handleReorderTabs }: DocumentTabsProps, ) { + const { viewportWidth, isCompact, isNarrow } = useViewportTier(); const [draggingTab, setDraggingTab] = useState(null); const [dragOverTab, setDragOverTab] = useState(null); const [contextMenu, setContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null); @@ -88,6 +90,8 @@ export function DocumentTabs( const contextMenuStyle = useMemo(() => contextMenu ? ({ left: contextMenu.x, top: contextMenu.y }) : {}, [ contextMenu, ]); + const compactTabs = useMemo(() => isCompact || viewportWidth < 860, [isCompact, viewportWidth]); + const compactContextMenu = useMemo(() => isNarrow, [isNarrow]); if (tabs.length === 0) { return ( @@ -100,7 +104,8 @@ export function DocumentTabs( return (
+ className="h-tab bg-bg-primary border-b border-border-subtle flex overflow-x-auto overflow-y-hidden" + data-compact-tabs={compactTabs}> {tabs.map((tab) => ( + onCloseTab={handleCloseTab} + compact={compactTabs} /> ))} {contextMenu && (
; } -const CloseButton = ({ onClose }: { onClose: () => void }) => ( - ); @@ -208,12 +216,13 @@ type ToggleButtonProps = { toggleFilters: MouseEventHandler; showFilters: boolean; activeFilterCount: number; + compact?: boolean; }; -function ToggleButton({ toggleFilters, showFilters, activeFilterCount }: ToggleButtonProps) { +function ToggleButton({ toggleFilters, showFilters, activeFilterCount, compact = false }: ToggleButtonProps) { const classes = useMemo(() => { const base = [ - "px-4 py-2.5", + compact ? "px-3 py-2" : "px-4 py-2.5", "border border-border-subtle rounded-md", "text-sm cursor-pointer flex items-center gap-1.5 transition-all duration-150", ]; @@ -231,7 +240,7 @@ function ToggleButton({ toggleFilters, showFilters, activeFilterCount }: ToggleB } return base.join(" "); - }, [showFilters, activeFilterCount]); + }, [showFilters, activeFilterCount, compact]); return ( {shortcut ? {shortcut} : null} @@ -103,14 +109,14 @@ const getStatusDisplay = (status: SaveStatus) => { } }; -function SaveStatusIndicator({ status }: { status: SaveStatus }) { +function SaveStatusIndicator({ status, compact = false }: { status: SaveStatus; compact?: boolean }) { const { Icon, text, color } = getStatusDisplay(status); return (
{Icon && } - {text} + {compact ? null : {text}}
); } @@ -121,6 +127,7 @@ export function Toolbar( ) { const { isSplitView, isFocusMode, isPreviewVisible, toggleSplitView, toggleFocusMode, togglePreviewVisible } = useToolbarState(); + const { viewportWidth, isCompact, isNarrow } = useViewportTier(); const icons: Record; size: IconSize }> = useMemo( () => ({ save: { Component: SaveIcon, size: "sm" }, @@ -134,40 +141,50 @@ export function Toolbar( [], ); + const iconOnly = useMemo(() => isNarrow, [isNarrow]); + const hideRefresh = useMemo(() => viewportWidth < 1080, [viewportWidth]); + const compactStatus = useMemo(() => viewportWidth < 960, [viewportWidth]); + return ( -
-
+
+
- - {onRefresh && } + shortcut="Ctrl+S" + iconOnly={isCompact} /> + + {onRefresh && !hideRefresh && ( + + )}
-
+
+ shortcut="Ctrl+\\" + iconOnly={iconOnly} /> + shortcut="Ctrl+P" + iconOnly={iconOnly} /> + shortcut="Ctrl+F" + iconOnly={iconOnly} />
@@ -176,9 +193,10 @@ export function Toolbar( icon={icons.export} label={isExportingPdf ? "Exporting" : "Export PDF"} onClick={onExportPdf} - disabled={isExportingPdf || isPdfExportDisabled} /> + disabled={isExportingPdf || isPdfExportDisabled} + iconOnly={iconOnly} /> )} - +
); diff --git a/src/components/layout/AppHeaderBar.tsx b/src/components/layout/AppHeaderBar.tsx index f8d5356..61d1449 100644 --- a/src/components/layout/AppHeaderBar.tsx +++ b/src/components/layout/AppHeaderBar.tsx @@ -1,59 +1,85 @@ import { Button } from "$components/Button"; +import { useViewportTier } from "$hooks/useViewportTier"; import { ChevronDownIcon, LibraryIcon, SearchIcon } from "$icons"; import { useAppHeaderBarState } from "$state/panel-selectors"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; -const AppTitle = ({ onToggleSidebar }: { onToggleSidebar: () => void }) => ( +const AppTitle = ({ onToggleSidebar, hideTitle }: { onToggleSidebar: () => void; hideTitle: boolean }) => (
-

Writer

+ {hideTitle ? null :

Writer

}
); const SearchRow = ( - { onOpenSearch, onToggleTabBar, tabBarCollapsed }: { + { onOpenSearch, onToggleTabBar, tabBarCollapsed, iconOnly, showSearchShortcut, compactTabLabel }: { onOpenSearch: () => void; onToggleTabBar: () => void; tabBarCollapsed: boolean; + iconOnly: boolean; + showSearchShortcut: boolean; + compactTabLabel: boolean; }, ) => (
); export const AppHeaderBar = () => { const { tabBarCollapsed, toggleSidebarCollapsed, toggleTabBarCollapsed, setShowSearch } = useAppHeaderBarState(); + const { viewportWidth, isCompact, isNarrow } = useViewportTier(); const handleOpenSearch = useCallback(() => { setShowSearch(true); }, [setShowSearch]); + const showSearchShortcut = useMemo(() => viewportWidth >= 1240, [viewportWidth]); + const iconOnly = useMemo(() => viewportWidth < 760, [viewportWidth]); + return ( -
- +
+ + tabBarCollapsed={tabBarCollapsed} + iconOnly={iconOnly} + showSearchShortcut={showSearchShortcut} + compactTabLabel={isNarrow} />
); }; diff --git a/src/components/layout/BackendAlerts.tsx b/src/components/layout/BackendAlerts.tsx index 1837815..e6cbeec 100644 --- a/src/components/layout/BackendAlerts.tsx +++ b/src/components/layout/BackendAlerts.tsx @@ -1,27 +1,56 @@ +import { AnimatePresence, motion } from "motion/react"; + type MissingLocation = { location_id: number; path: string }; type Conflict = { location_id: number; rel_path: string; conflict_filename: string }; - type BackendAlertsProps = { missingLocations: MissingLocation[]; conflicts: Conflict[] }; -export const BackendAlerts = ({ missingLocations, conflicts }: BackendAlertsProps) => ( - <> - {missingLocations.length > 0 && ( -
- Missing Locations -

- {missingLocations.length} location(s) could not be found. They may have been moved or deleted. -

-
- )} +const ALERT_INITIAL = { opacity: 0, y: 12 } as const; +const ALERT_ANIMATE = { opacity: 1, y: 0 } as const; +const ALERT_EXIT = { opacity: 0, y: 8 } as const; +const ALERT_TRANSITION = { duration: 0.18, ease: "easeOut" } as const; + +const MissingLocationsAlert = ({ count }: { count: number }) => ( + + Missing Locations +

+ {count} location(s) could not be found. They may have been moved or deleted. +

+
+); - {conflicts.length > 0 && ( -
0 ? "bottom-[120px]" : "bottom-8" - }`}> - Conflicts Detected -

{conflicts.length} file(s) have conflicts that need attention.

-
- )} - +const ConflictsAlert = ({ count }: { count: number }) => ( + + Conflicts Detected +

{count} file(s) have conflicts that need attention.

+
); + +export const BackendAlerts = ({ missingLocations, conflicts }: BackendAlertsProps) => { + const hasMissingLocations = missingLocations.length > 0; + const hasConflicts = conflicts.length > 0; + + if (!hasMissingLocations && !hasConflicts) { + return null; + } + + return ( +
+ + {hasMissingLocations ? : null} + {hasConflicts ? : null} + +
+ ); +}; diff --git a/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx b/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx index a881d53..fc3b56a 100644 --- a/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx +++ b/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx @@ -1,4 +1,6 @@ import { Button } from "$components/Button"; +import { Dialog } from "$components/Dialog"; +import { useViewportTier } from "$hooks/useViewportTier"; import { XIcon } from "$icons"; import { useCalmUiActions, @@ -252,31 +254,38 @@ type LayoutSettingsPanelProps = { export const LayoutSettingsPanel = ( { isVisible, onClose, quickCaptureEnabled, onQuickCaptureEnabledChange }: LayoutSettingsPanelProps, -) => - isVisible - ? ( -
-
- ) - : null; +) => { + const { isCompact, viewportWidth } = useViewportTier(); + const compactPanel = isCompact || viewportWidth < 920; + + return ( + +
+ + + + + +
+ + +
+ + +
+ +
+
+ ); +}; diff --git a/src/components/layout/SearchOverlay.tsx b/src/components/layout/SearchOverlay.tsx index c22fe03..04b82f8 100644 --- a/src/components/layout/SearchOverlay.tsx +++ b/src/components/layout/SearchOverlay.tsx @@ -19,7 +19,7 @@ export function SearchOverlay( { searchQuery, searchResults, isSearching, locations, filters, handleSearch, setFilters, handleSelectSearchResult }: SearchOverlayProps, ) { - const { isVisible, sidebarCollapsed, setShowSearch } = useSearchOverlayState(); + const { isVisible, setShowSearch } = useSearchOverlayState(); const handleClose = useCallback(() => { setShowSearch(false); }, [setShowSearch]); @@ -27,16 +27,12 @@ export function SearchOverlay( locations, ]); - if (!isVisible) { - return null; - } - return ( ; @@ -36,6 +37,9 @@ export type WorkspacePanelProps = { }; const SPLIT_PANEL_MIN_WIDTH = 280; +const SPLIT_PANEL_THRESHOLD = SPLIT_PANEL_MIN_WIDTH * 2 + 96; +const SIDEBAR_MIN_WIDTH = 220; +const SIDEBAR_MAX_WIDTH = 480; const FALLBACK_VIEWPORT_WIDTH = 1280; type MainPanelProps = { @@ -101,13 +105,13 @@ function getPanelMode(isSplitView: boolean, isPreviewVisible: boolean): PanelMod export function WorkspacePanel( { sidebar, toolbar, tabs, editor, preview, statusBar, calmUiVisibility }: WorkspacePanelProps, ) { + const { viewportWidth } = useViewportTier(FALLBACK_VIEWPORT_WIDTH); const { sidebarCollapsed } = useWorkspacePanelSidebarState(); const { isSplitView, isPreviewVisible } = useWorkspacePanelModeState(); const topBarsCollapsed = useWorkspacePanelTopBarsCollapsed(); const statusBarCollapsed = useWorkspacePanelStatusBarCollapsed(); const panelMode = useMemo(() => getPanelMode(isSplitView, isPreviewVisible), [isSplitView, isPreviewVisible]); - const viewportWidth = globalThis.innerWidth || FALLBACK_VIEWPORT_WIDTH; - const splitMaxWidth = Math.max(SPLIT_PANEL_MIN_WIDTH, viewportWidth - SPLIT_PANEL_MIN_WIDTH); + const sidebarMaxWidth = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, viewportWidth - 280)); const effectiveSidebarVisible = calmUiVisibility ? calmUiVisibility.sidebar && !sidebarCollapsed : !sidebarCollapsed; const effectiveTabBarVisible = calmUiVisibility ? calmUiVisibility.tabBar && !topBarsCollapsed : !topBarsCollapsed; @@ -115,19 +119,50 @@ export function WorkspacePanel( ? calmUiVisibility.statusBar && !statusBarCollapsed : !statusBarCollapsed; - const { size: sidebarWidth, isResizing, startResizing } = useResizable({ + const { size: sidebarWidth, isResizing, startResizing, setSize: setSidebarWidth } = useResizable({ initialSize: 280, - minSize: 220, - maxSize: 480, + minSize: SIDEBAR_MIN_WIDTH, + maxSize: sidebarMaxWidth, axis: "x", }); - const { size: splitEditorWidth, isResizing: isSplitResizing, startResizing: startSplitResizing } = useResizable({ + + const splitAvailableWidth = Math.max( + SPLIT_PANEL_MIN_WIDTH, + viewportWidth - (effectiveSidebarVisible ? sidebarWidth : 0), + ); + const splitMaxWidth = Math.max(SPLIT_PANEL_MIN_WIDTH, splitAvailableWidth - SPLIT_PANEL_MIN_WIDTH); + const splitFeasible = splitAvailableWidth >= SPLIT_PANEL_THRESHOLD; + const effectivePanelMode = panelMode === "split" && !splitFeasible ? "editor" : panelMode; + + const { + size: splitEditorWidth, + isResizing: isSplitResizing, + startResizing: startSplitResizing, + setSize: setSplitEditorWidth, + } = useResizable({ initialSize: Math.round(viewportWidth / 2), minSize: SPLIT_PANEL_MIN_WIDTH, maxSize: splitMaxWidth, axis: "x", }); + useEffect(() => { + if (sidebarWidth > sidebarMaxWidth) { + setSidebarWidth(sidebarMaxWidth); + } + }, [sidebarWidth, sidebarMaxWidth, setSidebarWidth]); + + useEffect(() => { + if (splitEditorWidth > splitMaxWidth) { + setSplitEditorWidth(splitMaxWidth); + return; + } + + if (splitEditorWidth < SPLIT_PANEL_MIN_WIDTH) { + setSplitEditorWidth(SPLIT_PANEL_MIN_WIDTH); + } + }, [splitEditorWidth, splitMaxWidth, setSplitEditorWidth]); + const handleSidebarResizeStart: PointerEventHandler = useCallback((event) => { event.preventDefault(); startResizing(event.clientX); @@ -161,7 +196,7 @@ export function WorkspacePanel( {effectiveTabBarVisible ? : null} (title export function PdfExportDialog( { isOpen, title, isExporting = false, errorMessage, onExport, onCancel }: PdfExportDialogProps, ) { + const { isCompact, viewportWidth } = useViewportTier(); const [options, setOptions] = useState(DEFAULT_OPTIONS); const handlePageSizeChange = useCallback((e: React.ChangeEvent) => { @@ -59,29 +62,43 @@ export function PdfExportDialog( await onExport(options); }, [onExport, options]); - if (isOpen) { - return ( -
-
- - - {errorMessage ?

{errorMessage}

: null} - - -
-
- ); - } + const compactPanel = isCompact || viewportWidth < 880; + const optionsContent = ( + + ); - return null; + return ( + +
+ + + {errorMessage ?

{errorMessage}

: null} +
{optionsContent}
+ +
+
+ ); } diff --git a/src/components/pdf/ExportDialog/ExportFooter.tsx b/src/components/pdf/ExportDialog/ExportFooter.tsx index 9b26ace..3b9cd56 100644 --- a/src/components/pdf/ExportDialog/ExportFooter.tsx +++ b/src/components/pdf/ExportDialog/ExportFooter.tsx @@ -7,14 +7,14 @@ export const PdfExportDialogFooter = ( isExporting?: boolean; }, ) => ( -
+
diff --git a/src/hooks/useViewportTier.ts b/src/hooks/useViewportTier.ts new file mode 100644 index 0000000..90e7097 --- /dev/null +++ b/src/hooks/useViewportTier.ts @@ -0,0 +1,65 @@ +import { useEffect, useMemo, useState } from "react"; + +export const VIEWPORT_BREAKPOINTS = { compactMax: 719, narrowMax: 1023, standardMax: 1439 } as const; + +export type ViewportTier = "compact" | "narrow" | "standard" | "wide"; + +const DEFAULT_FALLBACK_WIDTH = 1280; + +const getSafeViewportWidth = (fallbackWidth: number): number => { + if (typeof globalThis === "undefined" || typeof globalThis.innerWidth !== "number") { + return fallbackWidth; + } + + return Math.max(320, globalThis.innerWidth); +}; + +export const getViewportTier = (viewportWidth: number): ViewportTier => { + if (viewportWidth <= VIEWPORT_BREAKPOINTS.compactMax) { + return "compact"; + } + + if (viewportWidth <= VIEWPORT_BREAKPOINTS.narrowMax) { + return "narrow"; + } + + if (viewportWidth <= VIEWPORT_BREAKPOINTS.standardMax) { + return "standard"; + } + + return "wide"; +}; + +export function useViewportTier(fallbackWidth = DEFAULT_FALLBACK_WIDTH) { + const [viewportWidth, setViewportWidth] = useState(() => getSafeViewportWidth(fallbackWidth)); + + useEffect(() => { + if (typeof globalThis.addEventListener !== "function") { + return; + } + + const updateViewportWidth = () => { + setViewportWidth(getSafeViewportWidth(fallbackWidth)); + }; + + updateViewportWidth(); + globalThis.addEventListener("resize", updateViewportWidth); + return () => { + globalThis.removeEventListener("resize", updateViewportWidth); + }; + }, [fallbackWidth]); + + const tier = useMemo(() => getViewportTier(viewportWidth), [viewportWidth]); + + return useMemo( + () => ({ + viewportWidth, + tier, + isCompact: tier === "compact", + isNarrow: tier === "compact" || tier === "narrow", + isStandardUp: tier === "standard" || tier === "wide", + isWide: tier === "wide", + }), + [tier, viewportWidth], + ); +} diff --git a/src/state/panel-selectors.ts b/src/state/panel-selectors.ts index b703507..55e4a0d 100644 --- a/src/state/panel-selectors.ts +++ b/src/state/panel-selectors.ts @@ -1,6 +1,7 @@ -import type { AppTheme, CalmUiSettings, EditorFontFamily, FocusDimmingMode, StyleCheckSettings } from "$types"; +import type { CalmUiSettings, StyleCheckSettings } from "$types"; import { useShallow } from "zustand/react/shallow"; import { useAppStore } from "./stores/app"; +import { EditorPresentation } from "./types"; const FOCUS_MODE_STYLE_CHECK_SETTINGS: StyleCheckSettings = { enabled: false, @@ -79,13 +80,7 @@ export const useWorkspacePanelTopBarsCollapsed = () => useAppStore((state) => st export const useWorkspacePanelStatusBarCollapsed = () => useAppStore((state) => state.statusBarCollapsed); export const useSearchOverlayState = () => - useAppStore( - useShallow((state) => ({ - isVisible: state.showSearch, - sidebarCollapsed: state.sidebarCollapsed, - setShowSearch: state.setShowSearch, - })), - ); + useAppStore(useShallow((state) => ({ isVisible: state.showSearch, setShowSearch: state.setShowSearch }))); export const useFocusModePanelState = () => useAppStore( @@ -119,19 +114,6 @@ export const useToolbarState = () => })), ); -type EditorPresentation = { - theme: AppTheme; - showLineNumbers: boolean; - textWrappingEnabled: boolean; - syntaxHighlightingEnabled: boolean; - fontSize: number; - fontFamily: EditorFontFamily; - typewriterScrollingEnabled: boolean; - focusDimmingMode: FocusDimmingMode; - posHighlightingEnabled: boolean; - styleCheckSettings: StyleCheckSettings; -}; - export const useEditorPresentationState = () => useAppStore( useShallow((state): EditorPresentation => ({ @@ -167,3 +149,11 @@ export const useCalmUiActions = () => revealChromeTemporarily: state.revealChromeTemporarily, })), ); + +export type SidebarStateReturn = ReturnType; +export type ToolbarStateReturn = ReturnType; +export type EditorPresentationStateReturn = ReturnType; +export type WorkspacePanelSidebarStateReturn = ReturnType; +export type WorkspacePanelModeStateReturn = ReturnType; +export type TopBarsCollapsedReturn = ReturnType; +export type StatusBarCollapsedReturn = ReturnType; diff --git a/src/state/types.ts b/src/state/types.ts index 6357bce..d825db2 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -153,3 +153,16 @@ export type AppStore = & TabsActions & PdfExportState & PdfExportActions; + +export type EditorPresentation = { + theme: AppTheme; + showLineNumbers: boolean; + textWrappingEnabled: boolean; + syntaxHighlightingEnabled: boolean; + fontSize: number; + fontFamily: EditorFontFamily; + typewriterScrollingEnabled: boolean; + focusDimmingMode: FocusDimmingMode; + posHighlightingEnabled: boolean; + styleCheckSettings: StyleCheckSettings; +}; diff --git a/src/utils/date.ts b/src/utils/date.ts index 7e6f653..cd88a5e 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -4,3 +4,22 @@ export function formatDraftDate(date: Date = new Date()): string { const day = String(date.getDate()).padStart(2, "0"); return `${year}_${month}_${day}`; } + +export function formatStatusDate(dateString: string): string { + try { + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) { + return "—"; + } + + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); + } catch { + return "—"; + } +} -- 2.51.2