From 2360d1eab7dd4b1402f22d8af7e615b0d4cb3504 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 21 Mar 2026 09:40:15 -0500 Subject: [PATCH] feat: welcome screen for new and empty workspaces * refine editor text wrapping styles --- CHANGELOG.md | 9 + crates/core/src/atproto/leaflet.rs | 1 - docs/specs/image-handling.md | 121 +++++++++- docs/tasks/image-handling.md | 67 ++++++ docs/tasks/parking-lot.md | 6 +- src/App.css | 11 + src/__tests__/AppHeaderBar.test.tsx | 6 +- src/__tests__/Editor.test.tsx | 2 + src/__tests__/Toolbar.test.tsx | 25 +- src/__tests__/WorkspacePanel.test.tsx | 63 ++++- .../useDocumentSessionEffects.test.tsx | 36 ++- src/components/AppLayout/WelcomeScreen.tsx | 227 ++++++++++++++++++ src/components/AppLayout/WorkspacePanel.tsx | 62 ++++- src/components/Editor.tsx | 4 +- src/components/SearchPanel/SearchPanel.tsx | 21 +- src/hooks/app/useDocumentSessionEffects.ts | 13 +- .../controllers/useWorkspaceViewController.ts | 22 +- src/utils/text.ts | 4 + 18 files changed, 636 insertions(+), 64 deletions(-) create mode 100644 src/components/AppLayout/WelcomeScreen.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b42fc9f..64a44e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +## v0.3.0 + +### Features + +- AT Protocol integration (login with your [internet handle](https://internethandle.org/)) +- Import strings (snippets) from [Tangled](https://tangled.org/) +- Import [standard.site](https://standard.site/) posts ([Leaflet](https://leaflet.pub)) +- UI/Design overhaul - reduced visual clutter + ## v0.2.0 — 2026-03-20 ### Features diff --git a/crates/core/src/atproto/leaflet.rs b/crates/core/src/atproto/leaflet.rs index 93aad28..7da494a 100644 --- a/crates/core/src/atproto/leaflet.rs +++ b/crates/core/src/atproto/leaflet.rs @@ -122,7 +122,6 @@ fn render_block(block: &Block<'_>) -> Result { }; Ok(match &block.alignment { - // Some(alignment) if !alignment.is_empty() => format!("\n{}", alignment, body), Some(alignment) => match alignment { BlockAlignment::TextAlignCenter => format!("\n{}", body), BlockAlignment::TextAlignLeft => format!("\n{}", body), diff --git a/docs/specs/image-handling.md b/docs/specs/image-handling.md index 459f8d0..54912b2 100644 --- a/docs/specs/image-handling.md +++ b/docs/specs/image-handling.md @@ -3,7 +3,7 @@ title: Image Handling Spec updated: 2026-03-21 --- -> Goal: Support local image embedding in markdown documents with storage, preview, and lifecycle management. +> Goal: Support local image embedding in markdown documents with storage, preview, lifecycle management, AT Protocol blob sync, and PDF export. ## Problem @@ -101,6 +101,121 @@ The document index (`documents` table in SQLite) does **not** track images. Imag Orphaned images (not referenced by any document) accumulate over time. A future `image_cleanup` command can scan all documents in a location and remove unreferenced assets. This is **out of scope** for the initial implementation. +### AT Protocol Blob Sync + +Bridges local `.writer-assets/` images and AT Protocol blob references (`at://blob/CID`) so images survive publish and import round-trips. + +#### Publish direction (local → remote) + +When a document containing `.writer-assets/` image references is published to Leaflet/Standard.Site: + +1. **Scan** the markdown for `.writer-assets/` image paths. +2. **Read** each referenced file from the location's `.writer-assets/` directory. +3. **Upload** each file via `com.atproto.repo.uploadBlob` on the user's PDS. The PDS returns a `BlobRef` with a populated CID, MIME type, and size. +4. **Rewrite** the markdown image references from `.writer-assets/.` to `at://blob/` before building the Leaflet document. +5. **Populate** the `Image` block's blob metadata correctly (MIME type from extension, actual file size, aspect ratio if cheaply available) instead of the current hardcoded values (`application/octet-stream`, size 0). + +The rewrite is transient — it happens in-memory during the publish pipeline. The local document retains its `.writer-assets/` references. + +#### Import direction (remote → local) + +When a Leaflet/Standard.Site post containing blob images is imported: + +1. **Detect** `at://blob/` image references in the converted markdown. +2. **Download** each blob from the author's PDS via `com.atproto.sync.getBlob` (params: DID + CID). +3. **Import** the downloaded bytes through the existing `image_import` flow (hash, dedup, store in `.writer-assets/`). +4. **Rewrite** the markdown image references from `at://blob/` to `.writer-assets/.`. + +This means imported posts get fully local images that render in preview without network access. + +#### Tauri Commands + +##### `blob_upload` + +```rust +#[tauri::command] +pub async fn blob_upload( + location_id: LocationId, + asset_path: String, + auth: AuthSession, +) -> Result +``` + +- Reads the file from `.writer-assets/`, determines MIME type from extension. +- Calls `com.atproto.repo.uploadBlob` with the file bytes. +- Returns the `BlobRef` (CID, MIME type, size) for use in Leaflet document construction. + +##### `blob_download` + +```rust +#[tauri::command] +pub async fn blob_download( + location_id: LocationId, + did: String, + cid: String, +) -> Result +``` + +- Calls `com.atproto.sync.getBlob` with the DID and CID. +- Writes the response bytes through `image_import` (hash, dedup, store). +- Returns the local `.writer-assets/.` path. + +#### Metadata Accuracy + +The current `image_from_url` in `leaflet.rs` hardcodes blob metadata: + +```rust +mime_type: MimeType::new_static("application/octet-stream"), +size: 0, +``` + +With blob sync, `blob_upload` returns real metadata from the PDS. The publish pipeline must thread this metadata into the `Image` block construction so Leaflet clients render images correctly. + +### PDF Export with Embedded Images + +The PDF pipeline (`crates/markdown` → `@react-pdf/renderer`) currently has no image support. Extending it requires changes at both layers. + +#### Rust: PdfNode Image Variant + +Add an `Image` variant to `PdfNode`: + +```rust +pub enum PdfNode { + // ... existing variants ... + Image { src: String, alt: String }, +} +``` + +- `src`: the `.writer-assets/.` path as written in markdown. +- `alt`: the alt text from `![alt](src)`. + +Update `MarkdownTransformer::transform_to_pdf_nodes()` to emit `PdfNode::Image` when it encounters a Comrak image node, instead of silently dropping it. + +#### Frontend: Path Resolution + +The `@react-pdf/renderer` `` component accepts a `src` that can be a URL, a file path, or a base64 data URL. Since Tauri asset protocol URLs may not work inside the PDF renderer's internal fetch: + +- Resolve `.writer-assets/` paths to **base64 data URLs** before passing to the renderer. +- Use `convertFileSrc()` to get the Tauri asset URL, fetch the bytes via the webview, then encode to `data:;base64,...`. +- This is similar to the font preloading strategy already in `src/pdf/fonts.ts`. + +#### Frontend: MarkdownPdfDocument Rendering + +Add an `Image` case to the node renderer in `MarkdownPdfDocument.tsx`: + +```tsx +case "Image": + return ; +``` + +- Respect page margins — images should not overflow the content area. +- Preserve aspect ratio. + +#### Limitations + +- SVG images may not be supported by `@react-pdf/renderer`. Fall back to a placeholder or skip with a warning. +- Very large images should be resized to reasonable print dimensions to avoid bloating PDF file size. This can be deferred — initial implementation embeds at original resolution. + --- ## Scope Boundaries @@ -112,6 +227,8 @@ Orphaned images (not referenced by any document) accumulate over time. A future - Markdown reference insertion - Preview rendering via Tauri asset protocol - Single image delete command +- AT Protocol blob sync (upload on publish, download on import) +- PDF export with embedded images **Out of scope (future):** @@ -119,5 +236,3 @@ Orphaned images (not referenced by any document) accumulate over time. A future - Orphan cleanup - Image editing / cropping - Gallery / asset manager UI -- AT Protocol image sync (Leaflet blob ↔ local asset) -- PDF export with embedded images (depends on PDF pipeline) diff --git a/docs/tasks/image-handling.md b/docs/tasks/image-handling.md index 0f2c0f7..f04f5b8 100644 --- a/docs/tasks/image-handling.md +++ b/docs/tasks/image-handling.md @@ -56,11 +56,78 @@ updated: 2026-03-21 - [ ] Click-to-zoom (optional polish) - Click image in preview to open full-size overlay +## AT Protocol Blob Sync + +### Backend (Rust) + +- [ ] Implement `blob_upload` command + - Read file from `.writer-assets/`, determine MIME type from extension + - Call `com.atproto.repo.uploadBlob` on user's PDS + - Return `BlobRef` (CID, MIME type, size) +- [ ] Implement `blob_download` command + - Call `com.atproto.sync.getBlob` with DID + CID + - Pipe response bytes through `image_import` (hash, dedup, store) + - Return local `.writer-assets/.` path +- [ ] Fix `image_from_url` metadata in `leaflet.rs` + - Replace hardcoded `application/octet-stream` / size 0 with real values from `blob_upload` response + - Thread `BlobRef` metadata through publish pipeline into `Image` block construction +- [ ] Register `blob_upload` and `blob_download` in `lib.rs` + +### Publish Pipeline Integration + +- [ ] Add image rewrite step to publish flow + - Scan markdown for `.writer-assets/` image paths before Leaflet conversion + - Upload each via `blob_upload`, collect CID mapping + - Rewrite `.writer-assets/.` → `at://blob/` in-memory (don't mutate local document) + - Pass rewritten markdown to `markdown_to_leaflet_document` + +### Import Pipeline Integration + +- [ ] Add blob download step to Standard.Site post import + - After `post_get_markdown`, scan result for `at://blob/` image refs + - For each, call `blob_download` with author DID + CID + - Rewrite `at://blob/` → `.writer-assets/.` in the markdown + - Save the rewritten markdown to disk + +### Frontend + +- [ ] Add command builders in `src/ports/commands.ts` + - `blobUpload(locationId, assetPath, auth, onOk, onErr)` + - `blobDownload(locationId, did, cid, onOk, onErr)` +- [ ] Update Standard.Site import controller to call blob download + rewrite + +## PDF Export with Embedded Images + +### Backend (Rust) + +- [ ] Add `Image` variant to `PdfNode` enum in `crates/markdown/src/lib.rs` + - Fields: `src: String`, `alt: String` +- [ ] Update `transform_to_pdf_nodes()` in `crates/markdown/src/transformer.rs` + - Handle Comrak image nodes → emit `PdfNode::Image` +- [ ] Update `PdfRenderResult` serialization to include new variant + +### Frontend + +- [ ] Add image path resolution for PDF renderer + - Resolve `.writer-assets/` paths to base64 data URLs (similar to font preloading in `src/pdf/fonts.ts`) + - Use `convertFileSrc()` → fetch bytes → encode as `data:;base64,...` +- [ ] Add `Image` case to `MarkdownPdfDocument.tsx` node renderer + - Render `` with `maxWidth: 100%`, preserve aspect ratio +- [ ] Update `usePdfExport.tsx` to preload images before render + - Scan PdfNodes for Image variants, resolve all paths, then render +- [ ] Handle SVG gracefully — skip or render placeholder if `@react-pdf/renderer` doesn't support it + ## Test Plan - [ ] Test import with each supported format - [ ] Test dedup (import same image twice) - [ ] Test paste and drag-and-drop flows - [ ] Test preview rendering with nested document paths +- [ ] Test blob upload round-trip (local image → upload → verify CID returned) +- [ ] Test blob download round-trip (CID → download → verify stored in `.writer-assets/`) +- [ ] Test publish with images (`.writer-assets/` refs rewritten to `at://blob/` in output) +- [ ] Test import with images (`at://blob/` refs rewritten to `.writer-assets/` in saved markdown) +- [ ] Test PDF export with embedded images (images render in output PDF) +- [ ] Test PDF export with missing image (graceful fallback, no crash) - [ ] `pnpm test:run` + `cargo test` passing - [ ] `pnpm lint` + `pnpm check` clean diff --git a/docs/tasks/parking-lot.md b/docs/tasks/parking-lot.md index 263d874..1822a66 100644 --- a/docs/tasks/parking-lot.md +++ b/docs/tasks/parking-lot.md @@ -2,7 +2,7 @@ title: "Parking Lot" description: > A collection of ideas/proposals for new features and quick bug notes. -updated: 2026-03-19 +updated: 2026-03-21 --- 1. **Outline utilization** @@ -16,6 +16,4 @@ updated: 2026-03-19 --- -- We should let users pick editor and PDF font in settings and export -- The new/empty (when nothing is open) document/buffer isn't good UI. We should show a welcome screen with some options like create new, open existing, import, etc. -- Toggling off word wrap should be reflected in the editor by removing horizontal padding/margins +- We should let users distinctly pick editor and PDF font in settings and export diff --git a/src/App.css b/src/App.css index b664761..0754024 100644 --- a/src/App.css +++ b/src/App.css @@ -348,6 +348,17 @@ body, padding-right: 2rem; } +.editor-container.editor-container--scroll .cm-content { + max-width: none; + margin: 0; + padding-left: 0.35rem; +} + +.editor-container.editor-container--scroll .cm-line { + padding-left: 0; + padding-right: 0; +} + /* Markdown Preview Styles */ .preview-content { max-width: var(--spacing-preview-content-max-width); diff --git a/src/__tests__/AppHeaderBar.test.tsx b/src/__tests__/AppHeaderBar.test.tsx index 8b6a31c..c95357a 100644 --- a/src/__tests__/AppHeaderBar.test.tsx +++ b/src/__tests__/AppHeaderBar.test.tsx @@ -6,11 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock( "$state/selectors", - () => ({ - useAppHeaderBarState: vi.fn(), - useHelpSheetState: vi.fn(), - useLayoutSettingsUiState: vi.fn(), - }), + () => ({ useAppHeaderBarState: vi.fn(), useHelpSheetState: vi.fn(), useLayoutSettingsUiState: vi.fn() }), ); describe("AppHeaderBar", () => { diff --git a/src/__tests__/Editor.test.tsx b/src/__tests__/Editor.test.tsx index 8f22936..4ef69b3 100644 --- a/src/__tests__/Editor.test.tsx +++ b/src/__tests__/Editor.test.tsx @@ -53,6 +53,7 @@ describe(Editor, () => { it("should disable text wrapping when disabled via prop", () => { const { container } = render(); expect(container.querySelector(".cm-lineWrapping")).not.toBeInTheDocument(); + expect(screen.getByTestId("editor-container")).toHaveClass("editor-container--scroll"); }); it("should apply custom font family and size variables", () => { @@ -208,6 +209,7 @@ describe(Editor, () => { expect(secondEditorRoot).toBeInTheDocument(); expect(secondEditorRoot).toBe(firstEditorRoot); expect(container.querySelector(".cm-lineWrapping")).not.toBeInTheDocument(); + expect(screen.getByTestId("editor-container")).toHaveClass("editor-container--scroll"); expect(container.querySelector(".cm-content")).toHaveTextContent("Persistent"); }); diff --git a/src/__tests__/Toolbar.test.tsx b/src/__tests__/Toolbar.test.tsx index e2d675d..59f9b87 100644 --- a/src/__tests__/Toolbar.test.tsx +++ b/src/__tests__/Toolbar.test.tsx @@ -1,12 +1,22 @@ import { Toolbar } from "$components/Toolbar"; import { useViewportTier } from "$hooks/useViewportTier"; -import { useLayoutChromeActions, useLayoutSettingsUiState, useToolbarState } from "$state/selectors"; +import { + useLayoutChromeActions, + useLayoutChromeState, + useLayoutSettingsUiState, + useToolbarState, +} from "$state/selectors"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock( "$state/selectors", - () => ({ useToolbarState: vi.fn(), useLayoutSettingsUiState: vi.fn(), useLayoutChromeActions: vi.fn() }), + () => ({ + useToolbarState: vi.fn(), + useLayoutSettingsUiState: vi.fn(), + useLayoutChromeActions: vi.fn(), + useLayoutChromeState: vi.fn(), + }), ); vi.mock("$hooks/useViewportTier", () => ({ useViewportTier: vi.fn() })); @@ -35,6 +45,15 @@ describe("Toolbar", () => { toggleFilenameVisibility: vi.fn(), }); vi.mocked(useLayoutSettingsUiState).mockReturnValue({ isOpen: false, setOpen: vi.fn() }); + vi.mocked(useLayoutChromeState).mockReturnValue({ + sidebarCollapsed: false, + topBarsCollapsed: false, + statusBarCollapsed: false, + showSearch: false, + reduceMotion: false, + showFilenames: false, + createReadmeInNewLocations: true, + }); vi.mocked(useViewportTier).mockReturnValue({ viewportWidth: 1280, tier: "standard", @@ -93,7 +112,7 @@ describe("Toolbar", () => { render(); - fireEvent.click(screen.getByRole("button", { name: "Toggle Sidebar" })); + fireEvent.click(screen.getByRole("button", { name: "Hide Sidebar" })); expect(toggleSidebarCollapsed).toHaveBeenCalledOnce(); }); diff --git a/src/__tests__/WorkspacePanel.test.tsx b/src/__tests__/WorkspacePanel.test.tsx index 1fb3b2d..91f033b 100644 --- a/src/__tests__/WorkspacePanel.test.tsx +++ b/src/__tests__/WorkspacePanel.test.tsx @@ -10,6 +10,7 @@ import { useEditorPresentationActions, useEditorPresentationState, useLayoutChromeActions, + useLayoutChromeState, useLayoutSettingsUiState, useSidebarState, useToolbarState, @@ -28,7 +29,6 @@ import type { WorkspacePanelSidebarStateReturn, } from "$state/selectors"; import type { MarkdownPreviewStyle } from "$types"; -import { formatShortcut } from "$utils/shortcuts"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -39,6 +39,7 @@ vi.mock( useToolbarState: vi.fn(), useEditorPresentationActions: vi.fn(), useLayoutChromeActions: vi.fn(), + useLayoutChromeState: vi.fn(), useLayoutSettingsUiState: vi.fn(), useEditorPresentationState: vi.fn(), useWorkspacePanelSidebarState: vi.fn(), @@ -55,6 +56,8 @@ type SelectorOverrides = { toolbarState?: Partial; editorPresentationState?: Partial; setMarkdownPreviewStyle?: (value: MarkdownPreviewStyle) => void; + setShowSearch?: (value: boolean) => void; + toggleSidebarCollapsed?: () => void; workspacePanelSidebarState?: Partial; workspacePanelModeState?: Partial; topBarsCollapsed?: TopBarsCollapsedReturn; @@ -63,10 +66,13 @@ type SelectorOverrides = { type WorkspacePanelPropOverrides = { toolbar?: Partial; + onOpenImportSheet?: WorkspacePanelProps["onOpenImportSheet"]; + onOpenStandardSiteImportSheet?: WorkspacePanelProps["onOpenStandardSiteImportSheet"]; editor?: Partial; preview?: Partial; statusBar?: Partial; diagnostics?: Partial; + welcome?: Partial>; }; const createSidebarState = (overrides: Partial = {}): SidebarStateReturn => ({ @@ -152,16 +158,25 @@ const mockPanelSelectors = (overrides: SelectorOverrides = {}): void => { }); vi.mocked(useLayoutChromeActions).mockReturnValue({ setSidebarCollapsed: vi.fn(), - toggleSidebarCollapsed: vi.fn(), + toggleSidebarCollapsed: overrides.toggleSidebarCollapsed ?? vi.fn(), setTopBarsCollapsed: vi.fn(), toggleTabBarCollapsed: vi.fn(), setStatusBarCollapsed: vi.fn(), toggleStatusBarCollapsed: vi.fn(), - setShowSearch: vi.fn(), + setShowSearch: overrides.setShowSearch ?? vi.fn(), toggleShowSearch: vi.fn(), setFilenameVisibility: vi.fn(), toggleFilenameVisibility: vi.fn(), }); + vi.mocked(useLayoutChromeState).mockReturnValue({ + sidebarCollapsed: overrides.workspacePanelSidebarState?.sidebarCollapsed ?? true, + topBarsCollapsed: false, + statusBarCollapsed: false, + showSearch: false, + reduceMotion: false, + showFilenames: false, + createReadmeInNewLocations: true, + }); vi.mocked(useLayoutSettingsUiState).mockReturnValue({ isOpen: false, setOpen: vi.fn() }); vi.mocked(useEditorPresentationState).mockReturnValue( createEditorPresentationState(overrides.editorPresentationState), @@ -223,6 +238,8 @@ const mockPanelSelectors = (overrides: SelectorOverrides = {}): void => { const createWorkspacePanelProps = (overrides: WorkspacePanelPropOverrides = {}): WorkspacePanelProps => ({ toolbar: { saveStatus: "Idle", onSave: vi.fn(), ...overrides.toolbar }, + onOpenImportSheet: overrides.onOpenImportSheet, + onOpenStandardSiteImportSheet: overrides.onOpenStandardSiteImportSheet, editor: { initialText: "# Document", onChange: vi.fn(), @@ -253,6 +270,14 @@ const createWorkspacePanelProps = (overrides: WorkspacePanelPropOverrides = {}): onOpenSettings: vi.fn(), ...overrides.diagnostics, }, + welcome: { + isVisible: false, + hasLocations: true, + locationCount: 1, + documentCount: 1, + onAddLocation: vi.fn(), + ...overrides.welcome, + }, }); const renderWorkspacePanel = ( @@ -297,11 +322,11 @@ describe("WorkspacePanel", () => { const onToggleSidebar = vi.fn(); renderWorkspacePanel({ editor: { initialText: "# Visible" } }, { - sidebarState: { toggleSidebarCollapsed: onToggleSidebar }, + toggleSidebarCollapsed: onToggleSidebar, workspacePanelSidebarState: { sidebarCollapsed: false }, }); - fireEvent.click(screen.getByTitle(`Hide sidebar (${formatShortcut("Cmd+B")})`)); + fireEvent.click(screen.getByRole("button", { name: /hide sidebar/i })); expect(onToggleSidebar).toHaveBeenCalledOnce(); const separator = screen.getByRole("separator", { name: "Resize sidebar" }); @@ -388,4 +413,32 @@ describe("WorkspacePanel", () => { expect(onClose).toHaveBeenCalledOnce(); expect(onOpenSettings).not.toHaveBeenCalled(); }); + + it("renders the welcome screen and routes its actions", () => { + const onNewDocument = vi.fn(); + const onAddLocation = vi.fn(); + const onOpenImportSheet = vi.fn(); + const onOpenStandardSiteImportSheet = vi.fn(); + const setShowSearch = vi.fn(); + + renderWorkspacePanel({ + toolbar: { onNewDocument }, + onOpenImportSheet, + onOpenStandardSiteImportSheet, + welcome: { isVisible: true, hasLocations: true, locationCount: 2, documentCount: 4, onAddLocation }, + }, { workspacePanelSidebarState: { sidebarCollapsed: true }, setShowSearch }); + + expect(screen.getByTestId("workspace-welcome-screen")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /create new/i })); + fireEvent.click(screen.getByRole("button", { name: /open existing/i })); + fireEvent.click(screen.getByRole("button", { name: /import tangled/i })); + fireEvent.click(screen.getByRole("button", { name: /import standard\.site/i })); + fireEvent.click(screen.getByRole("button", { name: /add another location/i })); + + expect(onNewDocument).toHaveBeenCalledOnce(); + expect(setShowSearch).toHaveBeenCalledWith(true); + expect(onOpenImportSheet).toHaveBeenCalledOnce(); + expect(onOpenStandardSiteImportSheet).toHaveBeenCalledOnce(); + expect(onAddLocation).toHaveBeenCalledOnce(); + }); }); diff --git a/src/__tests__/useDocumentSessionEffects.test.tsx b/src/__tests__/useDocumentSessionEffects.test.tsx index 084fdab..65b21ab 100644 --- a/src/__tests__/useDocumentSessionEffects.test.tsx +++ b/src/__tests__/useDocumentSessionEffects.test.tsx @@ -18,22 +18,20 @@ const createArgs = (overrides: Partial = {}): Use activeDocRef: null, openDoc: vi.fn(), handleSelectDocument: vi.fn(), - handleNewDocument: vi.fn(), ...overrides, }); describe("useDocumentSessionEffects", () => { - it("waits for session hydration before creating a startup draft", () => { - const handleNewDocument = vi.fn(); - renderHook(() => useDocumentSessionEffects(createArgs({ isSessionHydrated: false, handleNewDocument }))); - expect(handleNewDocument).not.toHaveBeenCalled(); + it("waits for session hydration before showing the empty workspace state", () => { + const handleSelectDocument = vi.fn(); + renderHook(() => useDocumentSessionEffects(createArgs({ isSessionHydrated: false, handleSelectDocument }))); + expect(handleSelectDocument).not.toHaveBeenCalled(); }); - it("creates a new draft on startup when no tabs are restored", () => { - const handleNewDocument = vi.fn(); - renderHook(() => useDocumentSessionEffects(createArgs({ handleNewDocument }))); - expect(handleNewDocument).toHaveBeenCalled(); - expect(handleNewDocument).toHaveBeenCalledWith(LOCATION.id); + it("does not create a startup draft when no tabs are restored", () => { + const handleSelectDocument = vi.fn(); + renderHook(() => useDocumentSessionEffects(createArgs({ handleSelectDocument }))); + expect(handleSelectDocument).not.toHaveBeenCalled(); }); it("opens the active document when one is selected", () => { @@ -88,4 +86,22 @@ describe("useDocumentSessionEffects", () => { expect(openDoc).toHaveBeenCalledTimes(1); }); + + it("reselects an existing tab for an empty selected location instead of creating a draft", () => { + const handleSelectDocument = vi.fn(); + const otherDocRef: DocRef = { location_id: LOCATION.id, rel_path: "notes/archive.md" }; + + renderHook(() => + useDocumentSessionEffects( + createArgs({ + documentsCount: 0, + activeTab: null, + tabs: [{ id: "tab-1", docRef: otherDocRef, title: "Archive", isModified: false }], + handleSelectDocument, + }), + ) + ); + + expect(handleSelectDocument).toHaveBeenCalledWith(LOCATION.id, "notes/archive.md"); + }); }); diff --git a/src/components/AppLayout/WelcomeScreen.tsx b/src/components/AppLayout/WelcomeScreen.tsx new file mode 100644 index 0000000..d357cef --- /dev/null +++ b/src/components/AppLayout/WelcomeScreen.tsx @@ -0,0 +1,227 @@ +import { Button } from "$components/Button"; +import { FileAddIcon, FolderAddIcon, LibraryIcon, SearchIcon, StandardSiteIcon, Tangled } from "$icons"; +import { formatCount } from "$utils/text"; +import { useMemo } from "react"; + +type WelcomeMeta = { headline: string; description: string; locationSummary: string; documentSummary: string }; + +type WelcomeActionIcon = "create" | "search" | "tangled" | "standardSite"; + +type WelcomeAction = { + title: string; + description: string; + icon: WelcomeActionIcon; + onClick?: () => void; + disabled?: boolean; + iconClassName: string; + iconContainerClassName: string; +}; + +type WelcomeActionCardProps = WelcomeAction; + +type WelcomeScreenProps = { + hasLocations: boolean; + locationCount: number; + documentCount: number; + onCreateNewDocument?: () => void; + onOpenExisting: () => void; + onAddLocation: () => void; + onOpenImportSheet?: () => void; + onOpenStandardSiteImportSheet?: () => void; +}; + +function ActionIcon({ icon, className = "" }: { icon: WelcomeActionIcon; className?: string }) { + switch (icon) { + case "create": + return ; + case "search": + return ; + case "tangled": + return ; + case "standardSite": + return ; + } +} + +type WelcomeHeroProps = WelcomeMeta; + +function WelcomeSummaryCard({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function WelcomeHero({ headline, description, locationSummary, documentSummary }: WelcomeHeroProps) { + return ( +
+
+ + Workspace Ready +
+ +
+

+ {headline} +

+

{description}

+
+ +
+ + +
+
+ ); +} + +type WelcomeAsideProps = { hasLocations: boolean; onAddLocation: () => void }; + +function WelcomeAside({ hasLocations, onAddLocation }: WelcomeAsideProps) { + const copy = hasLocations + ? "Use the quick actions to stay in flow." + : "Once a location is added, Writer can create drafts locally and route imports into that workspace."; + const label = hasLocations ? "Add another location" : "Add your first location"; + + return ( + + ); +} + +function WelcomeActionCard( + { title, description, icon, onClick, disabled = false, iconClassName, iconContainerClassName }: + WelcomeActionCardProps, +) { + return ( + + ); +} + +function WelcomeActions({ actions }: { actions: WelcomeAction[] }) { + return ( +
+ {actions.map((action) => )} +
+ ); +} + +function WelcomeFrame( + { meta, hasLocations, onAddLocation, actions }: { meta: WelcomeMeta } & WelcomeAsideProps & { + actions: WelcomeAction[]; + }, +) { + return ( +
+
+
+ + +
+ +
+
+ ); +} + +export function WelcomeScreen( + { + hasLocations, + locationCount, + documentCount, + onCreateNewDocument, + onOpenExisting, + onAddLocation, + onOpenImportSheet, + onOpenStandardSiteImportSheet, + }: WelcomeScreenProps, +) { + const meta = useMemo( + () => ({ + headline: hasLocations ? "Nothing is open." : "Start by adding a location.", + description: hasLocations + ? "Create a fresh draft, reopen something from your library, or pull material in from an external source." + : "Writer needs a location before it can create drafts, save notes, or import anything into your workspace.", + locationSummary: hasLocations + ? `${formatCount(locationCount, "location", "locations")} connected` + : "No locations connected", + documentSummary: hasLocations + ? `${formatCount(documentCount, "document", "documents")} in the current location` + : "Imports stay disabled until a location is available", + }), + [hasLocations, locationCount, documentCount], + ); + + const actions = useMemo( + () => [{ + title: "Create new", + description: hasLocations + ? "Open a blank draft in the selected location." + : "Add a location first so the draft has somewhere to live.", + icon: "create", + onClick: onCreateNewDocument, + disabled: !hasLocations || !onCreateNewDocument, + iconClassName: "text-accent-blue", + iconContainerClassName: "bg-layer-03", + }, { + title: "Open existing", + description: hasLocations + ? "Search your library and jump straight into an existing document." + : "Connect a location first to browse or search stored work.", + icon: "search", + onClick: onOpenExisting, + disabled: !hasLocations, + iconClassName: "text-text-primary", + iconContainerClassName: "bg-layer-03", + }, { + title: "Import from Tangled", + description: "Pull in strings from Tangled and save them as local documents.", + icon: "tangled", + onClick: onOpenImportSheet, + disabled: !hasLocations || !onOpenImportSheet, + iconClassName: "text-accent-magenta", + iconContainerClassName: "bg-layer-03", + }, { + title: "Import Standard.Site Documents", + description: "Bring public posts into Writer for local editing and revision.", + icon: "standardSite", + onClick: onOpenStandardSiteImportSheet, + disabled: !hasLocations || !onOpenStandardSiteImportSheet, + iconClassName: "text-accent-green", + iconContainerClassName: "bg-layer-03", + }], + [hasLocations, onCreateNewDocument, onOpenExisting, onOpenImportSheet, onOpenStandardSiteImportSheet], + ); + + return ( +
+
+ +
+
+ ); +} diff --git a/src/components/AppLayout/WorkspacePanel.tsx b/src/components/AppLayout/WorkspacePanel.tsx index 4ffc1b1..18c76a7 100644 --- a/src/components/AppLayout/WorkspacePanel.tsx +++ b/src/components/AppLayout/WorkspacePanel.tsx @@ -1,3 +1,4 @@ +import { WelcomeScreen } from "$components/AppLayout/WelcomeScreen"; import { DocumentTabs } from "$components/DocumentTabs"; import { type EditorProps, EditorWithContainer } from "$components/Editor"; import { Preview, type PreviewProps } from "$components/Preview"; @@ -12,6 +13,7 @@ import { useViewportTier } from "$hooks/useViewportTier"; import { EyeIcon } from "$icons"; import { useEditorPresentationActions, + useLayoutChromeActions, useWorkspacePanelModeState, useWorkspacePanelSidebarState, useWorkspacePanelStatusBarCollapsed, @@ -46,6 +48,14 @@ export type WorkspaceDiagnosticsProps = { onOpenSettings: () => void; }; +export type WorkspaceWelcomeProps = { + isVisible: boolean; + hasLocations: boolean; + locationCount: number; + documentCount: number; + onAddLocation: () => void; +}; + export type WorkspacePanelProps = { toolbar: Pick< ToolbarProps, @@ -67,6 +77,7 @@ export type WorkspacePanelProps = { preview: WorkspacePreviewProps; statusBar: StatusBarProps; diagnostics: WorkspaceDiagnosticsProps; + welcome?: WorkspaceWelcomeProps; }; const SPLIT_PANEL_MIN_WIDTH = 280; @@ -103,7 +114,10 @@ function PreviewModeButton( } function PreviewHeader( - { previewStyle, onSelectMode }: { previewStyle: WorkspacePreviewProps["previewStyle"]; onSelectMode: (mode: PreviewMode) => void }, + { previewStyle, onSelectMode }: { + previewStyle: WorkspacePreviewProps["previewStyle"]; + onSelectMode: (mode: PreviewMode) => void; + }, ) { const activeMode = useMemo(() => previewStyleToMode(previewStyle), [previewStyle]); @@ -125,6 +139,11 @@ type MainPanelProps = { panelMode: PanelMode; editor: WorkspaceEditorProps; preview: WorkspacePreviewProps; + welcome?: WorkspaceWelcomeProps; + onCreateNewDocument?: () => void; + onOpenExistingDocument: () => void; + onOpenImportSheet?: () => void; + onOpenStandardSiteImportSheet?: () => void; onSelectPreviewMode: (mode: PreviewMode) => void; splitEditorWidth: number; isSplitResizing: boolean; @@ -144,7 +163,20 @@ function getPanelMode(isSplitView: boolean, isPreviewVisible: boolean): PanelMod } function MainPanel( - { panelMode, editor, preview, onSelectPreviewMode, splitEditorWidth, isSplitResizing, onSplitResizeStart }: MainPanelProps, + { + panelMode, + editor, + preview, + welcome, + onCreateNewDocument, + onOpenExistingDocument, + onOpenImportSheet, + onOpenStandardSiteImportSheet, + onSelectPreviewMode, + splitEditorWidth, + isSplitResizing, + onSplitResizeStart, + }: MainPanelProps, ) { const container = useMemo(() => { if (panelMode === "split") { @@ -156,6 +188,20 @@ function MainPanel( return { className: "flex min-h-0 min-w-0 flex-col w-full" }; }, [panelMode, splitEditorWidth]); + if (welcome?.isVisible) { + return ( + + ); + } + if (panelMode === "split") { return (
@@ -219,12 +265,13 @@ function Section({ children, initial, animate, exit, transition, className, styl } export function WorkspacePanel( - { toolbar, onOpenImportSheet, onOpenStandardSiteImportSheet, editor, preview, statusBar, diagnostics }: + { toolbar, onOpenImportSheet, onOpenStandardSiteImportSheet, editor, preview, statusBar, diagnostics, welcome }: WorkspacePanelProps, ) { const skipAnimation = useSkipAnimation(); const { viewportWidth } = useViewportTier(FALLBACK_VIEWPORT_WIDTH); const { setMarkdownPreviewStyle } = useEditorPresentationActions(); + const { setShowSearch } = useLayoutChromeActions(); const { sidebarCollapsed } = useWorkspacePanelSidebarState(); const { isSplitView, isPreviewVisible } = useWorkspacePanelModeState(); const topBarsCollapsed = useWorkspacePanelTopBarsCollapsed(); @@ -295,6 +342,10 @@ export function WorkspacePanel( setMarkdownPreviewStyle(previewModeToStyle(mode)); }, [setMarkdownPreviewStyle]); + const handleOpenExistingDocument = useCallback(() => { + setShowSearch(true); + }, [setShowSearch]); + const sidebarStyle = useMemo(() => ({ width: `${sidebarWidth}px` }), [sidebarWidth]); const sectionTransition = useMemo(() => skipAnimation ? NO_MOTION_TRANSITION : CHROME_SECTION.TRANSITION, [ @@ -366,6 +417,11 @@ export function WorkspacePanel( panelMode={effectivePanelMode} editor={editor} preview={preview} + welcome={welcome} + onCreateNewDocument={newDocumentHandler} + onOpenExistingDocument={handleOpenExistingDocument} + onOpenImportSheet={onOpenImportSheet} + onOpenStandardSiteImportSheet={onOpenStandardSiteImportSheet} onSelectPreviewMode={handleSelectPreviewMode} splitEditorWidth={splitEditorWidth} isSplitResizing={isSplitResizing} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index a452316..003c80c 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -537,7 +537,9 @@ export function Editor( return (
cn("overflow-y-auto px-3 py-3", compact ? "flex-1" : "max-h-[50vh]"), - [compact], - ); + const panelBodyClassName = useMemo(() => cn("overflow-y-auto px-3 py-3", compact ? "flex-1" : "max-h-[50vh]"), [ + compact, + ]); const containerClassName = useMemo( () => diff --git a/src/hooks/app/useDocumentSessionEffects.ts b/src/hooks/app/useDocumentSessionEffects.ts index 2ff4dcc..de5517c 100644 --- a/src/hooks/app/useDocumentSessionEffects.ts +++ b/src/hooks/app/useDocumentSessionEffects.ts @@ -12,7 +12,6 @@ type UseDocumentSessionEffectsArgs = { activeDocRef: DocRef | null; openDoc: (docRef: DocRef) => void; handleSelectDocument: (locationId: number, path: string) => void; - handleNewDocument: (locationId?: number) => void; }; export function useDocumentSessionEffects( @@ -27,7 +26,6 @@ export function useDocumentSessionEffects( activeDocRef, openDoc, handleSelectDocument, - handleNewDocument, }: UseDocumentSessionEffectsArgs, ): void { const startupDocumentReadyRef = useRef(false); @@ -45,11 +43,7 @@ export function useDocumentSessionEffects( startupDocumentReadyRef.current = true; startupDocumentRestoredRef.current = true; - - if (tabs.length === 0) { - handleNewDocument(selectedLocationId ?? locations[0]?.id); - } - }, [isSessionHydrated, isSidebarLoading, locations, selectedLocationId, tabs.length, handleNewDocument]); + }, [isSessionHydrated, isSidebarLoading, locations]); useEffect(() => { if (!activeDocRef) { @@ -80,9 +74,6 @@ export function useDocumentSessionEffects( const existingTab = tabs.find((tab) => tab.docRef.location_id === selectedLocationId); if (existingTab) { handleSelectDocument(existingTab.docRef.location_id, existingTab.docRef.rel_path); - return; } - - handleNewDocument(selectedLocationId); - }, [activeTab, documentsCount, handleNewDocument, handleSelectDocument, isSidebarLoading, selectedLocationId, tabs]); + }, [activeTab, documentsCount, handleSelectDocument, isSidebarLoading, selectedLocationId, tabs]); } diff --git a/src/hooks/controllers/useWorkspaceViewController.ts b/src/hooks/controllers/useWorkspaceViewController.ts index a2a1984..7adbece 100644 --- a/src/hooks/controllers/useWorkspaceViewController.ts +++ b/src/hooks/controllers/useWorkspaceViewController.ts @@ -1,4 +1,8 @@ -import type { WorkspaceEditorProps, WorkspacePanelProps } from "$components/AppLayout/WorkspacePanel"; +import type { + WorkspaceEditorProps, + WorkspacePanelProps, + WorkspaceWelcomeProps, +} from "$components/AppLayout/WorkspacePanel"; import { StatusBarProps } from "$components/StatusBar"; import type { StyleMatch } from "$editor/types"; import { useDocumentSessionEffects } from "$hooks/app/useDocumentSessionEffects"; @@ -83,6 +87,7 @@ export function useWorkspaceViewController(): WorkspaceViewController { handleSelectDocument, handleCreateDraftTab, handleCreateNewDocument, + handleAddLocation, handleRefreshSidebar, } = useWorkspaceController(); const atProto = useAtProtoController({ locations, selectedLocationId, refreshSidebar: handleRefreshSidebar }); @@ -117,6 +122,7 @@ export function useWorkspaceViewController(): WorkspaceViewController { editorModel.docRef, ]); const hasLocations = useMemo(() => locations.length > 0, [locations.length]); + const showWelcomeScreen = useMemo(() => isSessionHydrated && !activeTab, [activeTab, isSessionHydrated]); const cursorPosition = useMemo( () => ({ cursorLine: editorModel.cursorLine, cursorColumn: editorModel.cursorColumn }), @@ -178,7 +184,6 @@ export function useWorkspaceViewController(): WorkspaceViewController { activeDocRef, openDoc, handleSelectDocument, - handleNewDocument, }); useEditorPreviewEffects({ @@ -296,6 +301,17 @@ export function useWorkspaceViewController(): WorkspaceViewController { ], ); + const welcomeProps = useMemo( + () => ({ + isVisible: showWelcomeScreen, + hasLocations, + locationCount: locations.length, + documentCount: documents.length, + onAddLocation: handleAddLocation, + }), + [showWelcomeScreen, hasLocations, locations.length, documents.length, handleAddLocation], + ); + const workspacePanelProps = useMemo( () => ({ toolbar: toolbarProps, @@ -312,6 +328,7 @@ export function useWorkspaceViewController(): WorkspaceViewController { onClose: closeDiagnostics, onOpenSettings: openSettingsRoute, }, + welcome: welcomeProps, }), [ toolbarProps, @@ -326,6 +343,7 @@ export function useWorkspaceViewController(): WorkspaceViewController { handleSelectStyleMatch, closeDiagnostics, openSettingsRoute, + welcomeProps, ], ); diff --git a/src/utils/text.ts b/src/utils/text.ts index 28a03bb..2071d34 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -1,3 +1,7 @@ export function normalizeText(value: unknown): string { return typeof value === "string" ? value : ""; } + +export function formatCount(value: number, singular: string, plural: string): string { + return `${value.toLocaleString()} ${value === 1 ? singular : plural}`; +} -- 2.51.2