feat: mobile layout, PWA support (#27) (#27)

- Mobile-responsive editor with bottom drawer, ribbon nav, HUD overlay
- Touch support: single-finger tools, two-finger pinch-zoom
- PWA: manifest, service worker, installability
- Extract LayerTree from LayersPanel, shared utils (colorToCSS, initials, toolIcons)
- Constants moved to src/constants.ts, clipboard state to editor store
- reka-ui Popover/DropdownMenu in MobileHud

Co-authored-by: Danila Poyarkov <dev@dannote.net>
This commit is contained in:
Anton Soldatov 2026-03-05 19:51:51 +03:00 committed by GitHub
parent 661b83e749
commit 168c25c189
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 2782 additions and 405 deletions

View file

@ -44,3 +44,55 @@ jobs:
- name: Copy-paste detection
run: bun run test:dupes
preview:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
pull-requests: write
environment:
name: preview
url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: bun-${{ runner.os }}-
- run: bun install --frozen-lockfile
- run: bun run build
- uses: cloudflare/wrangler-action@v3
id: deploy
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=openpencil-app --branch=${{ github.head_ref }}
- name: Comment preview URL
uses: actions/github-script@v7
with:
script: |
const url = '${{ steps.deploy.outputs.deployment-url }}';
const marker = '<!-- preview-deploy -->';
const body = `${marker}\n🔗 Preview: ${url}`;
const { data: comments } = await github.rest.issues.listComments({
...context.repo, issue_number: context.issue.number
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
...context.repo, comment_id: existing.id, body
});
} else {
await github.rest.issues.createComment({
...context.repo, issue_number: context.issue.number, body
});
}

View file

@ -11,6 +11,10 @@
- Split tools into domain files (read, create, modify, structure, variables, vector, analyze) — easier to navigate and extend
- Replace inline type definitions with named types (`Color`, `Vector`, `SceneNode`) across the codebase
### Internal
- Mobile UI cleanup: extract shared `colorToCSS` util to core, `initials` to `src/utils/text`, `toolIcons` to `src/utils/tools`; replace hand-rolled dropdowns with reka-ui Popover/DropdownMenu; narrow `mobileDrawerSnap` type to string union; move magic numbers to constants; disable PWA service worker in dev mode
## 0.7.0 — 2026-03-05
### Features

View file

@ -48,6 +48,55 @@ See [`AGENTS.md`](./AGENTS.md) for the full architecture reference, code convent
- Icons via unplugin-icons (`<icon-lucide-*>`)
- Use existing deps and Reka UI components before hand-rolling (see AGENTS.md → Code quality)
## Test IDs (`data-test-id`)
Every interactive or structurally significant element must have a `data-test-id` attribute. These are used by Playwright E2E tests and must follow the naming convention below.
### Naming rules
- **kebab-case**, all lowercase
- Pattern: `{component}-{element}` or `{component}-{element}-{variant}`
- Mobile counterparts are prefixed with `mobile-`
- Dynamic IDs use template literals: `` :data-test-id="`toolbar-tool-${key.toLowerCase()}`" ``
### Nomenclature
| Prefix | Component | Examples |
|--------|-----------|---------|
| `toolbar-` | Desktop toolbar | `toolbar`, `toolbar-tool-select`, `toolbar-flyout-frame`, `toolbar-flyout-item-ellipse` |
| `mobile-toolbar-` | Mobile toolbar | `mobile-toolbar`, `mobile-toolbar-prev`, `mobile-toolbar-next`, `mobile-toolbar-tool-select`, `mobile-toolbar-flyout-frame`, `mobile-toolbar-copy`, `mobile-toolbar-front` |
| `mobile-toolbar-tools` | Mobile tools category | Container for drawing tools |
| `mobile-toolbar-edit` | Mobile edit category | Container for edit actions (copy, paste, cut, duplicate, delete) |
| `mobile-toolbar-arrange` | Mobile arrange category | Container for arrange actions (front, back, group, ungroup, lock) |
| `mobile-drawer-` | Mobile bottom drawer | `mobile-drawer`, `mobile-drawer-handle`, `mobile-drawer-pages`, `mobile-drawer-content`, `mobile-drawer-layers`, `mobile-drawer-design`, `mobile-drawer-code`, `mobile-drawer-ai` |
| `mobile-ribbon-` | Mobile bottom tab bar | `mobile-ribbon`, `mobile-ribbon-layers`, `mobile-ribbon-design`, `mobile-ribbon-code`, `mobile-ribbon-ai` |
| `layers-` | Layers panel | `layers-panel`, `layers-header`, `layers-tree`, `layers-item` |
| `pages-` | Pages panel | `pages-panel`, `pages-header`, `pages-item`, `pages-item-input`, `pages-add` |
| `properties-` | Properties panel | `properties-panel`, `properties-tab-design`, `properties-tab-code`, `properties-tab-ai`, `properties-zoom` |
| `design-` | Design tab | `design-node-header`, `design-multi-header`, `design-panel-single`, `design-panel-multi`, `design-panel-empty` |
| `position-` | Position section | `position-section`, `position-align-left`, `position-flip-horizontal`, `position-rotate-90` |
| `layout-` | Layout section | `layout-section`, `layout-add-auto`, `layout-remove-auto`, `layout-direction-horizontal` |
| `fill-` | Fill section | `fill-section`, `fill-section-add`, `fill-item` |
| `stroke-` | Stroke section | `stroke-section`, `stroke-section-add`, `stroke-item` |
| `effects-` | Effects section | `effects-section`, `effects-section-add`, `effects-item` |
| `export-` | Export section | `export-section`, `export-section-add`, `export-button`, `export-item` |
| `typography-` | Typography section | `typography-section`, `typography-missing-font` |
| `variables-` | Variables | `variables-section`, `variables-dialog`, `variables-add-variable` |
| `context-` | Context menu | `context-copy`, `context-paste`, `context-delete`, `context-group` |
| `color-` | Color picker | `color-picker-popover`, `color-picker-swatch`, `color-hex-input` |
| `fill-picker-` | Fill picker | `fill-picker-swatch`, `fill-picker-tab-solid`, `fill-picker-tab-gradient` |
| `font-picker-` | Font picker | `font-picker-trigger`, `font-picker-search`, `font-picker-item` |
| `chat-` | Chat / AI panel | `chat-panel`, `chat-input`, `chat-send-button`, `chat-messages` |
| `code-` | Code panel | `code-panel`, `code-panel-header`, `code-panel-copy` |
| `collab-` | Collaboration | `collab-popover`, `collab-share-button`, `collab-copy-link` |
| `canvas-` | Canvas | `canvas-area`, `canvas-element`, `canvas-loading` |
| `editor-` | Editor root | `editor-root`, `editor-document-name`, `editor-show-ui` |
| `app-` | App chrome | `app-logo`, `app-document-name`, `app-toggle-ui`, `app-select-trigger` |
| `tabbar-` | Tab bar | `tabbar-tab`, `tabbar-new`, `tabbar-close` |
| `scrub-input` | Scrub input | `scrub-input`, `scrub-input-field` |
| `toast-` | Toast notifications | `toast-item`, `toast-close`, `toast-copy-error` |
| `safari-banner` | Safari warning | `safari-banner`, `safari-banner-dismiss` |
## Test fixtures
`.fig` fixtures in `tests/fixtures/` are Git LFS. Use `git push --no-verify` to skip the slow LFS pre-push hook unless you changed `.fig` files.

544
bun.lock

File diff suppressed because it is too large Load diff

9
components.d.ts vendored
View file

@ -61,11 +61,14 @@ declare module 'vue' {
IconLucideImage: typeof import('~icons/lucide/image')['default']
IconLucideItalic: typeof import('~icons/lucide/italic')['default']
IconLucideKeyRound: typeof import('~icons/lucide/key-round')['default']
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideLink: typeof import('~icons/lucide/link')['default']
IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle')['default']
IconLucideMenu: typeof import('~icons/lucide/menu')['default']
IconLucideMessageCircle: typeof import('~icons/lucide/message-circle')['default']
IconLucidePlus: typeof import('~icons/lucide/plus')['default']
IconLucideRadius: typeof import('~icons/lucide/radius')['default']
IconLucideRedo2: typeof import('~icons/lucide/redo2')['default']
IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw')['default']
IconLucideRotateCw: typeof import('~icons/lucide/rotate-cw')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
@ -73,16 +76,22 @@ declare module 'vue' {
IconLucideSettings2: typeof import('~icons/lucide/settings2')['default']
IconLucideShare2: typeof import('~icons/lucide/share2')['default']
IconLucideSidebar: typeof import('~icons/lucide/sidebar')['default']
IconLucideSlidersHorizontal: typeof import('~icons/lucide/sliders-horizontal')['default']
IconLucideSparkles: typeof import('~icons/lucide/sparkles')['default']
IconLucideSquare: typeof import('~icons/lucide/square')['default']
IconLucideStrikethrough: typeof import('~icons/lucide/strikethrough')['default']
IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default']
IconLucideUnderline: typeof import('~icons/lucide/underline')['default']
IconLucideUndo2: typeof import('~icons/lucide/undo2')['default']
IconLucideUnlink: typeof import('~icons/lucide/unlink')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']
LayersPanel: typeof import('./src/components/LayersPanel.vue')['default']
LayerTree: typeof import('./src/components/LayerTree.vue')['default']
LayoutSection: typeof import('./src/components/properties/LayoutSection.vue')['default']
MobileDrawer: typeof import('./src/components/MobileDrawer.vue')['default']
MobileHud: typeof import('./src/components/MobileHud.vue')['default']
MobileRibbon: typeof import('./src/components/MobileRibbon.vue')['default']
NodeContextMenuContent: typeof import('./src/components/NodeContextMenuContent.vue')['default']
PageSection: typeof import('./src/components/properties/PageSection.vue')['default']
PagesPanel: typeof import('./src/components/PagesPanel.vue')['default']

View file

@ -7,6 +7,8 @@
<link rel="icon" type="image/png" sizes="128x128" href="/favicon-128.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#1e1e1e" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>OpenPencil</title>
<style>
body { margin: 0; background: #1e1e1e; }

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-03

View file

@ -0,0 +1,61 @@
## Context
OpenPencil web app is deployed to Cloudflare Pages at `app.openpencil.dev`. The build produces a ~11MB `dist/` with a 7MB CanvasKit WASM, ~2.4MB JS bundle, fonts, and icons. The app uses Vue 3 + Vite, renders entirely on a WebGL canvas (no DOM rendering for design content), and has two routes (`/` and `/share/:roomId`). There is no service worker or manifest — the web app cannot be installed or used offline.
The Tauri desktop app shares the same frontend but wraps it in a native shell with native file dialogs, Zstd compression, and system font access via Rust commands. PWA must not interfere with Tauri runtime.
## Goals / Non-Goals
**Goals:**
- Make the web app installable (A2HS prompt, standalone window)
- Precache critical assets (WASM, fonts, JS/CSS) so the app shell loads offline
- Generate the manifest and SW at build time with zero manual maintenance
- Conditionally skip SW registration inside Tauri webview
**Non-Goals:**
- Full offline editing (requires IndexedDB document persistence — separate change)
- Background sync or push notifications
- Caching user-uploaded images or .fig files in the SW
- Custom update UI (use `vite-plugin-pwa` autoUpdate for now)
## Decisions
### 1. Use `vite-plugin-pwa` with Workbox `generateSW`
**Rationale:** `vite-plugin-pwa` is the standard for Vite projects. `generateSW` (vs `injectManifest`) is simpler — Workbox auto-generates the SW with precache manifest from the build output. No custom SW logic needed at this stage.
**Alternatives considered:**
- Manual `workbox-cli` — more boilerplate, must maintain SW separately
- `injectManifest` — gives full SW control but overkill for asset precaching
### 2. `autoUpdate` registration type
**Rationale:** The app is a single-page editor. Auto-updating the SW on new versions avoids stale caches without needing a "New version available" prompt. Users always get the latest build on next visit.
**Alternatives considered:**
- `prompt` — shows update banner, but adds UI complexity for minimal benefit in an editor app where the session is typically fresh
### 3. Precache strategy: all build output including WASM
**Rationale:** The 7MB CanvasKit WASM is the critical asset. Without it, the app is a blank screen. Precaching it means repeat visits load instantly even on slow connections. Workbox's `maximumFileSizeToCacheInBytes` must be raised to ~8MB.
The `Inter-Regular.ttf` font in `public/` will also be precached as part of the glob.
### 4. Guard SW registration with `IS_TAURI`
**Rationale:** Inside Tauri's webview, a service worker would intercept network requests unnecessarily and could conflict with Tauri's IPC. The `IS_TAURI` constant from `@open-pencil/core` already exists and is the project convention for Tauri detection.
### 5. `standalone` display mode
**Rationale:** Matches the desktop app experience — no browser chrome. The editor uses the full viewport. `minimal-ui` would add a browser nav bar that wastes space and conflicts with the editor's own toolbar.
### 6. Icon generation from existing favicon-128
**Rationale:** The project already has `favicon-128.png` and `apple-touch-icon.png`. PWA requires 192×192 and 512×512. These need to be created once and committed to `public/`. A maskable variant (with padding) is needed for Android adaptive icons.
## Risks / Trade-offs
- [Large precache payload (~10MB)] → Acceptable for a design editor. First visit already downloads all this; SW just caches it for subsequent visits. Cloudflare Pages has no bandwidth cost.
- [SW cache invalidation] → Workbox uses content hashes in the precache manifest. New builds automatically invalidate changed assets.
- [Tauri interference] → Mitigated by `IS_TAURI` guard. SW is never registered in Tauri context.
- [Cloudflare Pages MIME type for `.webmanifest`] → Cloudflare Pages serves `.webmanifest` with correct `application/manifest+json` MIME type by default.

View file

@ -0,0 +1,30 @@
## Why
OpenPencil's web version (app.openpencil.dev) is a full-featured design editor deployed to Cloudflare Pages, but it lacks offline capability and native app-like UX. Users lose their work if they navigate away or lose connection. Adding PWA support enables installability, offline access to the shell/cached assets, and positions the web app closer to the Tauri desktop experience.
## What Changes
- Add a web app manifest (`manifest.webmanifest`) with app metadata, icons, and display mode
- Add a service worker for asset precaching (WASM, fonts, JS/CSS bundles) and runtime caching
- Integrate `vite-plugin-pwa` into the Vite build pipeline
- Register the service worker in the app entry point (browser only, skip in Tauri)
- Add PWA meta tags to `index.html` (theme-color, manifest link, apple status bar)
- Generate required icon sizes from existing assets (192×192, 512×512, maskable)
## Capabilities
### New Capabilities
- `pwa`: Progressive Web App support — manifest, service worker, installability, offline shell caching
### Modified Capabilities
- `desktop-app`: Guard service worker registration to avoid conflicts with Tauri runtime
## Impact
- **Vite config**: New plugin (`vite-plugin-pwa`) with workbox configuration
- **Dependencies**: `vite-plugin-pwa` (dev dependency)
- **index.html**: Meta tags for theme-color, manifest link
- **src/main.ts**: Service worker registration (conditional on non-Tauri)
- **public/**: Manifest file, PWA icons (192, 512, maskable)
- **CI**: No changes needed — `bun run build` already deploys to Cloudflare Pages, manifest + SW output automatically included in `dist/`
- **Bundle size impact**: Service worker file (~1KB generated), manifest file (<1KB). No impact on app JS bundle. WASM (~7MB) and fonts will be precached by the SW.

View file

@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: Tauri v2 desktop shell
The editor SHALL run as a native desktop app via Tauri v2 with the web frontend loaded in a webview. The app identifier SHALL be `net.dannote.open-pencil`. When running inside the Tauri webview, the service worker SHALL NOT be registered to avoid intercepting Tauri IPC and native file system requests.
#### Scenario: Desktop app launch
- **WHEN** user runs `bun run tauri dev`
- **THEN** a native desktop window opens with the editor UI and CanvasKit rendering
#### Scenario: No service worker in Tauri
- **WHEN** the app loads inside the Tauri webview
- **THEN** `navigator.serviceWorker.register` is never called and no SW is active

View file

@ -0,0 +1,51 @@
## ADDED Requirements
### Requirement: Web app manifest
The app SHALL include a `manifest.webmanifest` file linked from `index.html` with `name` "OpenPencil", `short_name` "OpenPencil", `display` "standalone", `start_url` "/", `theme_color` "#1e1e1e", `background_color` "#1e1e1e", and icon entries for 192×192, 512×512, and maskable variants.
#### Scenario: Manifest served correctly
- **WHEN** a browser requests `/manifest.webmanifest`
- **THEN** the response is a valid JSON manifest with `display: "standalone"` and at least three icon entries
#### Scenario: Installability criteria met
- **WHEN** Chrome audits the web app for PWA installability
- **THEN** all criteria pass: manifest present, service worker registered, start_url responds, icons present
### Requirement: Service worker precaching
The build SHALL generate a service worker that precaches all build output assets including JavaScript bundles, CSS, WASM files, fonts, and icons. The Workbox `maximumFileSizeToCacheInBytes` SHALL be set to at least 8MB to accommodate the CanvasKit WASM (~7MB).
#### Scenario: Assets cached on first visit
- **WHEN** a user visits the app for the first time in a browser
- **THEN** the service worker installs and all precache-manifest assets are stored in CacheStorage
#### Scenario: App shell loads offline
- **WHEN** a user has previously visited the app and then loses network connectivity
- **THEN** the app shell (HTML, JS, CSS, WASM, fonts) loads from cache and the canvas initializes
### Requirement: Auto-update strategy
The service worker SHALL use an auto-update strategy: when a new version is detected, the SW activates immediately without requiring user interaction. The `skipWaiting` and `clientsClaim` options SHALL be enabled.
#### Scenario: Transparent update on new deployment
- **WHEN** a new version is deployed and the user revisits the app
- **THEN** the new service worker activates and serves updated assets without a reload prompt
### Requirement: PWA meta tags
`index.html` SHALL include `<meta name="theme-color" content="#1e1e1e">` and `<link rel="manifest" href="/manifest.webmanifest">`. The existing `apple-touch-icon` link SHALL be preserved.
#### Scenario: Theme color matches app background
- **WHEN** the browser reads meta tags
- **THEN** `theme-color` is `#1e1e1e` matching the app's dark background
### Requirement: PWA icons
The `public/` directory SHALL contain PWA icons at 192×192 (`pwa-192.png`), 512×512 (`pwa-512.png`), and a maskable icon at 512×512 (`pwa-maskable-512.png`) with safe-zone padding.
#### Scenario: Icon sizes available
- **WHEN** the manifest is parsed
- **THEN** icons at 192×192 (purpose "any"), 512×512 (purpose "any"), and 512×512 (purpose "maskable") are referenced and the files exist
### Requirement: Vite plugin integration
`vite-plugin-pwa` SHALL be configured in `vite.config.ts` using `generateSW` mode with `autoUpdate` registration type. The plugin SHALL be added to the Vite plugins array.
#### Scenario: Build produces SW
- **WHEN** `bun run build` completes
- **THEN** `dist/sw.js` and `dist/workbox-*.js` files exist alongside the precache manifest

View file

@ -0,0 +1,21 @@
## 1. Dependencies & Icons
- [x] 1.1 Install `vite-plugin-pwa` as a dev dependency
- [x] 1.2 Create PWA icons: `public/pwa-192.png`, `public/pwa-512.png`, `public/pwa-maskable-512.png` from existing `public/favicon-128.png`
## 2. Vite Plugin Configuration
- [x] 2.1 Add `vite-plugin-pwa` to `vite.config.ts` with `generateSW` mode, `autoUpdate` registration, `standalone` display, theme/background colors `#1e1e1e`, icon entries, and `maximumFileSizeToCacheInBytes` ≥ 8MB
## 3. HTML Meta Tags
- [x] 3.1 Add `<meta name="theme-color" content="#1e1e1e">` and `<link rel="manifest" href="/manifest.webmanifest">` to `index.html`
## 4. Service Worker Registration
- [x] 4.1 Register the service worker in `src/main.ts` using `virtual:pwa-register`, guarded by `IS_TAURI` to skip registration in the Tauri webview
## 5. Verification
- [x] 5.1 Run `bun run build` and verify `dist/sw.js`, `dist/manifest.webmanifest`, and PWA icon files exist in output
- [x] 5.2 Run `bun run check` to confirm lint and typecheck pass

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-05

View file

@ -0,0 +1,36 @@
## Context
PR #27 introduced mobile UI components that duplicated utilities and magic numbers already present elsewhere, used hand-rolled dropdowns instead of reka-ui, and used overly-wide types. All changes are purely refactoring/convention fixes — no new features.
## Goals / Non-Goals
**Goals:**
- Eliminate code duplication flagged in review
- Follow project conventions (reka-ui, constants file, store types, useBreakpoints)
- Fix PWA dev-mode caching bug
**Non-Goals:**
- Changing any mobile UX behavior
- Refactoring LayerTree/LayersPanel merge (too large, separate task)
## Decisions
**`colorToCSS` → `packages/core/src/color.ts`**: Composes with existing `colorToRgba255` — single implementation, zero duplication. Both `CollabPanel` and `MobileHud` import from core.
**`initials` → `src/utils/text.ts`**: App-level utility (not core). New `src/utils/` directory follows convention for shared non-component helpers.
**`toolIcons` — export from `Toolbar.vue`**: Toolbar is the authoritative source. Named export `toolIcons` imported by `MobileHud`.
**`mobileDrawerSnap` type: `'closed' | 'half' | 'full'`**: The numeric `0` value was a legacy relic. All code in `MobileDrawer` and `MobileRibbon` already treated `0` as `'closed'`. Migrate to string union, remove the ternary workarounds.
**Clipboard in editor store**: Module-level mutable state in `Toolbar.vue` is not accessible to other components. Store field `clipboardHtml: string` with actions `mobileCopy`, `mobileCut`, `mobilePaste` in editor store.
**reka-ui Popover for peers list, DropdownMenu for app menu**: Project convention. `onClickOutside` workaround replaced by reka-ui's built-in outside-click handling.
**`useBreakpoints` in `EditorView`**: Consistent with `@vueuse/core` usage throughout the codebase.
## Risks / Trade-offs
[`mobileDrawerSnap` type change] → All assignment sites set string values — no runtime risk, verified by TypeScript compiler.
[Moving clipboard to store] → Slightly more verbose, but correct — the store is the right owner of shared mutable state.

View file

@ -0,0 +1,45 @@
## Why
PR #27 (mobile layout) received review feedback from @dannote flagging 12 issues: code duplication, violation of project conventions (constants, reka-ui components, store types), and a dev-mode PWA bug. These must be fixed before the branch can be merged.
## What Changes
- Extract `toolIcons` map from `MobileHud.vue` — export it from `Toolbar.vue` and import in `MobileHud.vue`
- Extract `colorToCSS` to `packages/core/src/color.ts` (compose with `colorToRgba255`)
- Extract `initials` to a shared util (`src/utils/text.ts`)
- Remove duplicate `colorToCSS` / `initials` from `MobileHud.vue` and `CollabPanel.vue` — import from shared locations
- Replace hand-rolled peers dropdown with `PopoverRoot`/`PopoverContent` from reka-ui
- Replace hand-rolled menu dropdown in `MobileHud.vue` with `DropdownMenuRoot`/`DropdownMenuContent` from reka-ui
- Tighten `mobileDrawerSnap` type in `editor.ts` from `number | string | null` to `'closed' | 'half' | 'full'`; update all usages
- Move magic numbers `RIBBON_H`, `HALF_FRAC`, `HUD_TOP` from `MobileDrawer.vue` to `src/constants.ts`
- Move `SWIPE_THRESHOLD`, `SWIPE_MAX_DURATION` from `MobileRibbon.vue` to `src/constants.ts`
- Move `ACTION_TOAST_DURATION` from `Toolbar.vue` to `src/constants.ts`
- Move `internalClipboard` module-level mutable state from `Toolbar.vue` into the editor store
- Replace `animate-` custom class with `tw-animate-css` in `MobileHud.vue` (line 264)
- Disable PWA service worker in dev: `devOptions: { enabled: false }` in `vite.config.ts`
- Replace `useMediaQuery('(max-width: 767px)')` in `EditorView.vue` with `useBreakpoints`
## Capabilities
### New Capabilities
- `shared-color-utils`: `colorToCSS` utility exported from `@open-pencil/core` color module
- `shared-text-utils`: `initials` string utility in `src/utils/text.ts`
### Modified Capabilities
_(none — no spec-level behavior changes, purely code quality / convention fixes)_
## Impact
- `packages/core/src/color.ts` — new export `colorToCSS`
- `src/utils/text.ts` — new file with `initials`
- `src/constants.ts` — new constants: `RIBBON_H`, `HALF_FRAC`, `HUD_TOP`, `SWIPE_THRESHOLD`, `SWIPE_MAX_DURATION`, `ACTION_TOAST_DURATION`
- `src/stores/editor.ts` — type of `mobileDrawerSnap` narrowed; new `clipboardData` field
- `src/components/Toolbar.vue` — exports `toolIcons`; removes `internalClipboard`; uses constant
- `src/components/MobileHud.vue` — imports shared utils/icons; uses reka-ui Popover + DropdownMenu; uses `tw-animate-css`
- `src/components/CollabPanel.vue` — imports `colorToCSS` from core, `initials` from shared util
- `src/components/MobileDrawer.vue` — imports constants
- `src/components/MobileRibbon.vue` — imports constants
- `src/views/EditorView.vue` — uses `useBreakpoints` instead of raw `useMediaQuery`
- `vite.config.ts` — SW disabled in dev

View file

@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: colorToCSS exported from core
The `colorToCSS` function SHALL be exported from `packages/core/src/color.ts` and compose with the existing `colorToRgba255` helper. It SHALL convert a `Color` value to a CSS `rgb(r, g, b)` string.
#### Scenario: Converts color to CSS string
- **WHEN** `colorToCSS({ r: 0.96, g: 0.26, b: 0.21, a: 1 })` is called
- **THEN** it returns `"rgb(245, 66, 54)"`
#### Scenario: Available as named import from core
- **WHEN** consumer writes `import { colorToCSS } from '@open-pencil/core'`
- **THEN** the import resolves without error and the function works as expected

View file

@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: initials utility in src/utils/text.ts
The `initials` function SHALL be exported from `src/utils/text.ts`. It SHALL take a name string and return up to 2 uppercase initials, falling back to `"?"` for empty input.
#### Scenario: Two-word name
- **WHEN** `initials("John Doe")` is called
- **THEN** it returns `"JD"`
#### Scenario: Single word
- **WHEN** `initials("Alice")` is called
- **THEN** it returns `"A"`
#### Scenario: Empty string fallback
- **WHEN** `initials("")` is called
- **THEN** it returns `"?"`

View file

@ -0,0 +1,58 @@
## 1. Shared utilities
- [x] 1.1 Add `colorToCSS(c: Color): string` to `packages/core/src/color.ts` — compose with existing `colorToRgba255`: `const {r,g,b} = colorToRgba255(c); return \`rgb(${r}, ${g}, ${b})\``; add to named exports in `packages/core/src/index.ts`
- [x] 1.2 Create `src/utils/text.ts` — export `initials(name: string): string` that splits on spaces, takes first letter of each word, uppercases, slices to 2 chars, falls back to `"?"`
- [x] 1.3 Create `src/utils/tools.ts` — move `toolIcons: Record<Tool, Component>` map here (import `Tool` type from `@/stores/editor`, import icon components); export named `toolIcons`
## 2. Constants
- [x] 2.1 Add to `src/constants.ts`: `RIBBON_H = 44`, `HALF_FRAC = 3 / 7`, `HUD_TOP = 12 + 32 + 6 + 32 + 12` (from `MobileDrawer.vue`); `SWIPE_THRESHOLD = 30`, `SWIPE_MAX_DURATION = 500` (from `MobileRibbon.vue`); `ACTION_TOAST_DURATION = 800` (from `Toolbar.vue`)
## 3. Store changes
- [x] 3.1 In `editor.ts` change `mobileDrawerSnap` type to `'closed' | 'half' | 'full'` and default to `'closed'`. Update all assignment sites: `src/views/EditorView.vue:72` (`=== 0` check → remove, only keep `=== 'closed'`; line 73 stays), `src/components/MobileRibbon.vue` (lines 23,25,36,39,61,65,68 — replace `!== 0``!== 'closed'`, `= 0``= 'closed'`), `src/components/MobileDrawer.vue:23` (setter: `v === 'closed' ? 0 : v` → just `v`), `src/composables/use-keyboard.ts:95` (remove `=== 0 ||`, keep `=== 'closed'` check)
- [x] 3.2 In `editor.ts` add `clipboardHtml: ''` to state; add store actions: `mobileCopy()` — creates `new DataTransfer()`, calls `writeCopyData(transfer)`, sets `state.clipboardHtml = transfer.getData('text/html')`; `mobileCut()` — calls `mobileCopy()` then `deleteSelected()`; `mobilePaste()` — calls `pasteFromHTML(state.clipboardHtml)` when `state.clipboardHtml` is non-empty. (`writeCopyData` and `pasteFromHTML` already exist in the store.)
## 4. Toolbar.vue
- [x] 4.1 Remove `toolIcons` definition from `Toolbar.vue`; import from `@/utils/tools`
- [x] 4.2 Remove `internalClipboard` module-level variable; replace local `mobileCopy`/`mobileCut`/`mobilePaste` functions with `store.mobileCopy()`, `store.mobileCut()`, `store.mobilePaste()` calls
- [x] 4.3 Replace `const ACTION_TOAST_DURATION = 800` (removed from store-level logic); ensure `ACTION_TOAST_DURATION` is imported from `@/constants` wherever the literal `800` was used
## 5. MobileHud.vue
- [x] 5.1 Remove the local `toolIcons` map and all icon component imports used only for it; import `toolIcons` from `@/utils/tools`
- [x] 5.2 Import `colorToCSS` from `@open-pencil/core`; remove local `colorToCSS` function
- [x] 5.3 Import `initials` from `@/utils/text`; remove local `initials` function
- [x] 5.4 Replace hand-rolled peers `<div v-if="peersOpen">` with reka-ui: wrap Online badge button in `<PopoverTrigger as-child>`, add `<PopoverPortal><PopoverContent :modal="false">` with the peers list inside; remove `peersOpen` ref
- [x] 5.5 Replace hand-rolled app menu `<div v-if="menuOpen">` with reka-ui: wrap menu button in `<DropdownMenuTrigger as-child>`, add `<DropdownMenuPortal><DropdownMenuContent>` with `<DropdownMenuItem>` per action; remove `menuRef` ref, `menuOpen` ref, and `onClickOutside` import/call
- [x] 5.6 Replace `<style scoped>` `toast-fade-*` CSS block + `<Transition name="toast-fade">` with `<Transition enter-active-class="animate-fade-in" leave-active-class="animate-fade-out">` (tw-animate-css is already imported in `app.css`)
## 6. CollabPanel.vue
- [x] 6.1 Import `colorToCSS` from `@open-pencil/core`; remove local definition
- [x] 6.2 Import `initials` from `@/utils/text`; remove local definition
## 7. MobileDrawer.vue
- [x] 7.1 Import `RIBBON_H`, `HALF_FRAC`, `HUD_TOP` from `@/constants`; remove local `const` declarations
- [x] 7.2 Update computed `snap` setter: remove `v === 'closed' ? 0 : v` — set `store.state.mobileDrawerSnap` directly to `v` (already a `Snap` value after 3.1)
## 8. MobileRibbon.vue
- [x] 8.1 Import `SWIPE_THRESHOLD`, `SWIPE_MAX_DURATION` from `@/constants`; remove local `const` declarations
- [x] 8.2 All `mobileDrawerSnap` comparison/assignment sites are already covered by task 3.1 — verify no remaining `0` literals remain
## 9. EditorView.vue
- [x] 9.1 Replace `useMediaQuery('(max-width: 767px)')` with `const breakpoints = useBreakpoints({ mobile: 768 })` and `const isMobile = breakpoints.smaller('mobile')` — uses `< 768` matching the original `<= 767px` behavior
## 10. vite.config.ts
- [x] 10.1 Change `devOptions: { enabled: true }` to `devOptions: { enabled: false }` to stop the SW from intercepting dev requests and causing stale cache bugs
## 11. Verification
- [x] 11.1 Run `bun run check` — no type errors or lint warnings
- [x] 11.2 Run `bun run test:unit` — no regressions
- [x] 11.3 Add entry to `CHANGELOG.md` Unreleased section (internal refactor: mobile PR cleanup)

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-03

View file

@ -0,0 +1,113 @@
## Context
OpenPencil's EditorView uses a horizontal SplitterGroup with three panels: LayersPanel (18%), EditorCanvas (64%), PropertiesPanel (18%). This works on desktop but fails on small screens. The canvas already supports touch pinch-zoom and multi-touch gestures via `use-canvas-input.ts`.
## Goals / Non-Goals
**Goals:**
- Usable editor on viewports ≥320px wide through 768px
- Canvas gets maximum screen real estate on mobile
- All panel content accessible via bottom drawer
- Touch-friendly interactions (≥44px targets, swipe gestures)
- Zero impact on desktop layout
**Non-Goals:**
- Tablet landscape split view
- PWA / offline mobile support
- Native mobile app
## Decisions
### 1. Custom drawer instead of vaul-vue
Initially planned vaul-vue, but replaced with a custom implementation using CSS `translateY` + `transition` + `requestAnimationFrame`. Reasons:
- vaul-vue portals to `<body>`, creating z-index conflicts with CanvasKit WebGL surface
- Custom drawer gives precise control over snap points and drag behavior
- Simpler DOM structure — drawer stays in normal flow, no portal
Implementation: `MobileDrawer.vue` uses `renderSnap` ref with `nextTick()` + `requestAnimationFrame()` pattern for smooth open animation (force browser to render initial position before transitioning).
### 2. Snap points: closed / half / full
- **Closed**: `translateY(100%)` — fully hidden, only ribbon visible
- **Half**: 3/7 of viewport height — shows enough panel content for quick edits
- **Full**: viewport minus ribbon (44px) minus HUD top area (94px) — stops below the active tool indicator
Drag gestures on the entire drawer body (not just handle) — swipe down from full → half → closed, swipe up from closed → half → full. Threshold: 50px displacement.
### 3. MobileHud overlay
New `MobileHud.vue` component rendered as absolute overlay on the canvas area:
- **Top-left**: Undo/redo buttons (rounded circles, shadow), active tool indicator below
- **Top-center**: Online badge (when connected) + action toast (fade animation)
- **Top-right**: Share button + burger menu (New, Open, Save, Export, Zoom to fit)
- Root div has `pointer-events-none`, child groups have `pointer-events-auto`
- `@touchstart.stop` prevents touch events leaking to canvas
### 4. Ribbon outside the drawer
MobileRibbon is `fixed bottom-0 z-40` — always visible regardless of drawer state. Drawer content appears above the ribbon. The ribbon has:
- Layers and Design as separate buttons (icon + label when active)
- Code and AI as icon-only buttons on the right
- Swipe up/down on ribbon triggers drawer snap changes
- Tab tap when drawer closed auto-opens to half snap
### 5. Toolbar 3-category system (mobile only)
Desktop toolbar unchanged. Mobile toolbar splits into 3 categories:
- **Category 0**: Drawing tools (all TOOLS from store, with flyout dropdowns)
- **Category 1**: Edit actions (Copy, Paste, Cut, Duplicate, Delete)
- **Category 2**: Arrange actions (Front, Back, Group, Ungroup, Lock)
Arrow buttons navigate between categories. Container width animates smoothly: all categories stacked in same space, active one `relative` (sets width), others `absolute` with opacity transition. Width animation via inline `transition: width 250ms ease`.
Edit/Arrange actions show toast via `store.state.actionToast` (displayed in MobileHud).
### 6. Touch-to-mouse synthesis
`use-canvas-input.ts` handles touch events on canvas:
- Single finger on HAND tool → direct pan (existing pinch-zoom behavior)
- Single finger on any other tool → `syntheticMouse()` creates MouseEvent from Touch, delegates to `onMouseDown`/`onMouseMove`/`onMouseUp`
- Two-finger pinch → pinch-zoom (existing)
- `@touchstart.stop` on HUD and toolbar prevents leaking to canvas
### 7. Internal clipboard for mobile
Mobile browsers restrict clipboard API access outside user gesture chains. Copy/paste buttons in toolbar use an in-memory `internalClipboard` variable:
- Copy: `store.writeCopyData(new DataTransfer())` → store HTML in variable
- Paste: `store.pasteFromHTML(internalClipboard)`
- Cut: copy + `store.deleteSelected()`
### 8. Rulers hidden on mobile
`use-canvas.ts` skips ruler rendering when `isMobile` is true. Rulers take valuable screen space and are impractical on touch devices.
### 9. Z-index layering
| Element | Z-index | Position |
|---------|---------|----------|
| Canvas | base | Normal flow |
| Toolbar | z-10 | Absolute, above ribbon |
| MobileDrawer | z-30 | Fixed, above canvas |
| MobileRibbon | z-40 | Fixed bottom |
| Popovers/dropdowns | z-50 | Portal |
| HUD | z-10 | Absolute overlay on canvas |
### 10. State in editor store
```
activeRibbonTab: 'panels' | 'code' | 'ai' | null (null = no highlight when drawer closed)
panelMode: 'layers' | 'design'
mobileDrawerSnap: 0 | 'half' | 'full' | 'closed'
actionToast: string | null
```
## Risks / Trade-offs
**[Trade-off] Custom drawer vs library** — More code to maintain, but full control over behavior and no portal issues.
**[Trade-off] Internal clipboard** — Copy/paste only works within the same session, not across apps. Acceptable limitation for mobile.
**[Trade-off] AppMenu hidden on mobile** — File operations accessible via burger menu in HUD. Not all desktop menu items available on mobile.
**[Risk] Virtual keyboard** — May push viewport when editing text in drawer. Start with default behavior, iterate if needed.

View file

@ -0,0 +1,46 @@
## Why
The editor has a fixed three-column desktop layout (layers | canvas | properties) using reka-ui Splitter. On mobile/tablet viewports (<768px), the side panels consume all usable space, making the canvas unusable. There's no responsive behavior, no breakpoint detection, and no mobile-optimized UI. The canvas already handles touch pinch-zoom, so the rendering layer is ready the chrome needs to adapt.
## What Changes
- Detect mobile viewport via `useMediaQuery('(max-width: 767px)')` from `@vueuse/core` in EditorView
- On mobile: hide desktop side panels, show canvas fullscreen with HUD overlay
- Add a custom bottom drawer (CSS translateY + transition) that slides up to reveal panel content. Snap points: closed, half (~3/7 viewport), full (viewport minus HUD area)
- Fixed ribbon tab bar at viewport bottom — always visible and thumb-reachable
- Ribbon tabs: Layers and Design as separate buttons (with label when active), Code and AI as icon-only buttons on the right
- Extract layer tree into `LayerTree.vue` for reuse in both desktop LayersPanel and mobile drawer
- Render DesignPanel, CodePanel, ChatPanel directly in mobile drawer via `v-show` (preserve ChatPanel state)
- PagesPanel in compact drawer header; AppMenu hidden on mobile
- MobileHud: undo/redo buttons, active tool indicator, online badge with peers dropdown, action toast, Share button, burger menu (New/Open/Save/Export/Zoom to fit)
- Mobile toolbar with 3 categories (Tools/Edit/Arrange), arrow navigation, animated width transitions
- Touch-to-mouse event synthesis for non-HAND tools in canvas input
- Rulers hidden on mobile
- Internal clipboard buffer for copy/paste (mobile browser clipboard API limitations)
- Fix `showUI` initialization to `true` (was gated by matchMedia)
- ⌘J shortcut bridges mobile/desktop: toggles `activeRibbonTab` on mobile
- Mobile state (`activeRibbonTab`, `panelMode`, `mobileDrawerSnap`, `actionToast`) in editor store
- Z-index layering: canvas < toolbar (z-10) < ribbon (z-40) < drawer (z-30) < popovers (z-50)
- Desktop layout completely unchanged
## Capabilities
### New Capabilities
- `mobile-layout`: Responsive mobile/tablet layout with custom bottom drawer, fixed ribbon tabs, MobileHud overlay, touch tool support, and adaptive panel switching
### Modified Capabilities
- `editor-ui`: EditorView gains responsive breakpoint detection and conditional rendering of desktop vs mobile chrome
## Impact
- `src/views/EditorView.vue` — conditional desktop/mobile layout
- `src/components/MobileDrawer.vue` — new: custom drawer with translateY transitions and drag gestures
- `src/components/MobileRibbon.vue` — new: fixed bottom tab bar
- `src/components/MobileHud.vue` — new: overlay with undo/redo, tool indicator, online badge, toast, burger menu
- `src/components/LayerTree.vue` — new: extracted from LayersPanel
- `src/components/LayersPanel.vue` — refactored to use LayerTree
- `src/components/Toolbar.vue` — 3 mobile categories with animated width, select-none, touch-friendly
- `src/stores/editor.ts` — new state: `activeRibbonTab`, `panelMode`, `mobileDrawerSnap`, `actionToast`; fix `showUI` init
- `src/composables/use-keyboard.ts` — ⌘J mobile bridge
- `src/composables/use-canvas-input.ts` — touch→mouse event synthesis for non-HAND tools
- `src/composables/use-canvas.ts` — rulers hidden on mobile

View file

@ -0,0 +1,7 @@
## MODIFIED Requirements
### Requirement: Bottom toolbar
The toolbar SHALL be positioned at the bottom of the screen with tool selection: Select (V), Frame (F), Section (S, in Frame flyout), Rectangle (R), Ellipse (O), Line (L), Polygon (flyout), Star (flyout), Text (T), Hand (H), Pen (P). On mobile viewports (<768px), the toolbar SHALL split into 3 categories navigated by arrow buttons: Category 0 (drawing tools with flyouts), Category 1 (Copy/Paste/Cut/Duplicate/Delete), Category 2 (Front/Back/Group/Ungroup/Lock). Buttons SHALL be `size-8` inside an `h-11` container with `rounded-[8px]` box and `rounded-[6px]` buttons. The toolbar SHALL be positioned above the MobileRibbon bar. Container width SHALL animate (250ms ease) when switching categories. All mobile buttons SHALL have `select-none border-none`. Edit/arrange actions SHALL trigger action toasts via `store.state.actionToast`.
### Requirement: Resizable panels
The left (layers) and right (properties) panels SHALL be resizable via reka-ui Splitter components on desktop viewports (≥768px). On mobile viewports, panels SHALL render inside the bottom drawer instead of the SplitterGroup.

View file

@ -0,0 +1,84 @@
## ADDED Requirements
### Requirement: Mobile viewport detection
The editor SHALL detect mobile viewports using a `(max-width: 767px)` media query via `useMediaQuery` from `@vueuse/core` in EditorView. The detection SHALL be reactive — rotating a device or resizing a browser window SHALL switch layouts without page reload.
### Requirement: Mobile canvas fullscreen
On mobile viewports, the EditorCanvas SHALL fill the entire viewport. The desktop SplitterGroup with LayersPanel and PropertiesPanel SHALL NOT render. Rulers SHALL be hidden on mobile.
### Requirement: Custom bottom drawer
The editor SHALL display a custom bottom drawer on mobile viewports containing panel content. The drawer SHALL use CSS `translateY` transitions (not a portal-based library). Three snap states: closed (fully hidden), half (~3/7 viewport height), full (viewport minus ribbon and HUD area). The drawer SHALL support drag gestures on the entire drawer body with a 50px threshold. The active snap SHALL be stored in `store.state.mobileDrawerSnap`. Swipe down from full goes to half, then to closed. Swipe up from closed goes to half, then to full.
#### Scenario: Smooth open animation
- **WHEN** the drawer transitions from closed to half
- **THEN** it animates via CSS transition (not instant jump), using requestAnimationFrame to ensure initial position renders before transition
#### Scenario: Drag to dismiss
- **WHEN** user swipes down >50px from half snap
- **THEN** drawer transitions to closed and `activeRibbonTab` resets to null
### Requirement: Fixed bottom ribbon tab bar
The editor SHALL render a MobileRibbon as a fixed bar at the viewport bottom (`fixed bottom-0 z-40`), outside the drawer. Tabs: Layers and Design as separate buttons (icon + label when active), Code and AI as icon-only buttons on the right. Visible at all times. Touch targets ≥44px. Safe area inset padding via `env(safe-area-inset-bottom)`.
#### Scenario: Ribbon swipe gestures
- **WHEN** user swipes up on the ribbon while drawer is closed
- **THEN** drawer opens to half snap
#### Scenario: Tab tap auto-opens drawer
- **WHEN** user taps a tab while drawer is collapsed
- **THEN** drawer animates to half snap
#### Scenario: Tab tap toggles drawer
- **WHEN** user taps the already-active tab while drawer is open
- **THEN** drawer closes and tab highlight resets to null
### Requirement: MobileHud overlay
The editor SHALL render a MobileHud component as an absolute overlay on the canvas area with:
- Top-left: Undo/redo buttons + active tool indicator (animated icon change)
- Top-center: Online badge (when connected, with peers dropdown) + action toast (800ms fade)
- Top-right: Share button + burger menu (New, Open, Save, Export, Zoom to fit)
The root SHALL have `pointer-events-none` with `pointer-events-auto` on interactive groups. `@touchstart.stop` SHALL prevent touch events from leaking to canvas.
#### Scenario: Action toast display
- **WHEN** user taps an edit/arrange action in the toolbar
- **THEN** a toast with the action name appears in the HUD center for 800ms
#### Scenario: Burger menu file operations
- **WHEN** user taps the burger menu button
- **THEN** a dropdown shows New, Open, Save, Export, Zoom to fit options
### Requirement: Mobile toolbar categories
On mobile, the toolbar SHALL split into 3 categories: drawing tools (Category 0), edit actions (Category 1: Copy/Paste/Cut/Duplicate/Delete), arrange actions (Category 2: Front/Back/Group/Ungroup/Lock). Arrow buttons navigate between categories. The container width SHALL animate smoothly (250ms ease) when switching categories. Buttons SHALL be `size-8` inside an `h-11` container. All buttons SHALL have `select-none` and `border-none`.
#### Scenario: Category switch animation
- **WHEN** user taps the right arrow from Category 0
- **THEN** the container width animates to Category 1's width and icons crossfade via opacity transition
#### Scenario: Edit action with toast
- **WHEN** user taps Delete in Category 1
- **THEN** the selected node is deleted and "Delete" toast shows in MobileHud
### Requirement: Touch-to-mouse event synthesis
`use-canvas-input.ts` SHALL synthesize MouseEvents from TouchEvents for non-HAND tools. Single-finger touch on SELECT/FRAME/RECTANGLE/etc. SHALL trigger `onMouseDown`/`onMouseMove`/`onMouseUp` with coordinates from the touch. Two-finger pinch-zoom SHALL continue to work. HAND tool SHALL use direct pan handling (no mouse synthesis).
### Requirement: Internal clipboard for mobile
Copy/paste toolbar buttons SHALL use an in-memory clipboard buffer instead of system clipboard API. Copy stores HTML via `store.writeCopyData(new DataTransfer())`. Paste reads from the buffer via `store.pasteFromHTML()`.
### Requirement: showUI initialization
The `showUI` store flag SHALL be initialized to `true` (not gated by matchMedia). On mobile with `showUI=true`: canvas + HUD + toolbar + ribbon + drawer. On mobile with `showUI=false`: only canvas.
### Requirement: ⌘J shortcut on mobile
On mobile, ⌘J SHALL toggle `activeRibbonTab` between current tab and `'ai'`, opening the drawer if collapsed. On desktop, existing behavior unchanged.
### Requirement: Extracted LayerTree component
The layer tree (tree nodes, drag reorder, expand/collapse, context menu, visibility toggle) SHALL be extracted from LayersPanel into `LayerTree.vue`. LayersPanel uses LayerTree internally. MobileDrawer uses LayerTree directly.
### Requirement: Panel content preservation
All panels in the mobile drawer SHALL use `v-show` instead of `v-if` to preserve component state during tab switches.
### Requirement: Desktop layout unchanged
On desktop viewports (≥768px), the existing SplitterGroup layout SHALL render unchanged. Mobile components SHALL NOT render.
### Requirement: Full drawer height limit
When the drawer is at full snap, its height SHALL stop below the active tool indicator in the MobileHud, with the same spacing as the HUD has from the viewport top. Full height = viewport - ribbon height (44px) - HUD top area (94px).

View file

@ -0,0 +1,67 @@
## 1. Store & State
- [x] 1.1 Add mobile state to editor store (`src/stores/editor.ts`): `activeRibbonTab: 'panels' | 'code' | 'ai' | null` (default `null`), `panelMode: 'layers' | 'design'` (default `'design'`), `mobileDrawerSnap: 0 | 'half' | 'full' | 'closed'` (default `0`), `actionToast: string | null` (default `null`)
- [x] 1.2 Fix `showUI` initialization: change from `matchMedia(...)` to `true`
## 2. Extract LayerTree Component
- [x] 2.1 Create `src/components/LayerTree.vue` — extract tree logic from LayersPanel: reka-ui TreeRoot/TreeItem, drag reorder, expand/collapse, context menu, visibility icon. Import order: reka-ui → vue → @vueuse → icons → @/ → relative
- [x] 2.2 Refactor `src/components/LayersPanel.vue` to use `<LayerTree />`
## 3. MobileRibbon Component
- [x] 3.1 Create `src/components/MobileRibbon.vue` — fixed bar at viewport bottom (`fixed bottom-0 z-40`), outside the drawer. Layers/Design as separate buttons (icon + label when active), Code/AI as icon-only buttons on right. 44px touch targets. `env(safe-area-inset-bottom)` padding. All tabs have `select-none outline-none transition-colors`. No `hover:` states — mobile only uses state-based styles
- [x] 3.2 Swipe gestures on ribbon: swipe up opens drawer (half/full), swipe down closes. `SWIPE_THRESHOLD = 30`, `SWIPE_MAX_DURATION = 500`
- [x] 3.3 Tab tap toggles: tap active tab while drawer open → close drawer + reset `activeRibbonTab` to null
## 4. MobileDrawer Component
- [x] 4.1 Create `src/components/MobileDrawer.vue` — custom drawer using CSS `translateY` + `transition` (NOT vaul-vue). Snap states: closed, half (3/7 viewport), full (viewport - 44px ribbon - 94px HUD). `renderSnap` ref with `nextTick()` + `requestAnimationFrame()` for smooth open animation
- [x] 4.2 Drag gestures on entire drawer body (not just handle). 50px threshold. Swipe down: full→half→closed. Swipe up: closed→half→full. Drag handle with `select-none`
- [x] 4.3 Drawer header: compact PagesPanel (horizontal scrollable) with border-b
- [x] 4.4 Panel content via `v-show`: LayerTree, DesignPanel, CodePanel, ChatPanel. `@touchstart.stop @touchmove.stop` on content area to prevent canvas interaction
## 5. MobileHud Component
- [x] 5.1 Create `src/components/MobileHud.vue` — absolute overlay with `pointer-events-none` root, `pointer-events-auto` on interactive groups. `@touchstart.stop` on root
- [x] 5.2 Top-left: Undo/redo buttons (`size-8 rounded-full border border-border shadow-md select-none`) + active tool indicator (`border-accent/30`, animated icon change via `Transition mode="out-in"`)
- [x] 5.3 Top-center: Online badge with peers dropdown (simple div toggle, not reka-ui Popover) + action toast (`toast-fade` transition, 800ms duration from `ACTION_TOAST_DURATION`)
- [x] 5.4 Top-right: Share button + burger menu (`onClickOutside` for dismiss). Menu items: New, Open, Save, Export, Zoom to fit. Each with Lucide icon. `border-none bg-transparent outline-none select-none`
## 6. EditorView Responsive Layout
- [x] 6.1 Add `isMobile = useMediaQuery('(max-width: 767px)')` in EditorView. Conditional: desktop SplitterGroup vs mobile layout
- [x] 6.2 Mobile layout: EditorCanvas + MobileHud (collab props forwarded) + Toolbar + MobileRibbon + MobileDrawer
- [x] 6.3 Wire `@tab-change` on MobileRibbon: auto-open drawer to half if collapsed
## 7. Keyboard Shortcut: ⌘J on mobile
- [x] 7.1 In `use-keyboard.ts`, detect `isMobile`. On ⌘J: mobile toggles `activeRibbonTab` between current and `'ai'` (opens drawer if collapsed); desktop unchanged
## 8. Toolbar Mobile Adaptation
- [x] 8.1 3 categories: Category 0 (drawing tools), Category 1 (Copy/Paste/Cut/Duplicate/Delete), Category 2 (Front/Back/Group/Ungroup/Lock). Arrow navigation buttons
- [x] 8.2 Animated container width: active category `relative` (sets width), others `absolute left-2 top-1/2 -translate-y-1/2` with opacity transition. Width via `scrollWidth` measurement + `transition: width 250ms ease`
- [x] 8.3 Internal clipboard: `mobileCopy()`, `mobileCut()`, `mobilePaste()` using in-memory buffer
- [x] 8.4 Action toast: `onActionTap()` sets `store.state.actionToast` for `ACTION_TOAST_DURATION` (800ms)
- [x] 8.5 All buttons: `select-none border-none`, `rounded-[8px]` container, `rounded-[6px]` buttons, `h-11` container with `size-8` buttons
## 9. Touch & Canvas
- [x] 9.1 `use-canvas-input.ts`: `syntheticMouse()` helper creates MouseEvent from Touch. Single finger on non-HAND tool → delegate to mouse handlers. Two-finger → pinch-zoom (existing)
- [x] 9.2 `use-canvas.ts`: hide rulers on mobile via `!isMobile` check
## 10. Code Style Alignment
- [x] 10.1 Import ordering: reka-ui → vue → icons → @vueuse@/ → relative → type imports
- [x] 10.2 No semicolons (codebase convention)
- [x] 10.3 `select-none` on all tappable elements
- [x] 10.4 `outline-none` on focusable elements (MobileRibbon tabs, menu items)
- [x] 10.5 Magic numbers extracted: `ACTION_TOAST_DURATION`, `SWIPE_MAX_DURATION`, `SWIPE_THRESHOLD`, `RIBBON_H`, `HALF_FRAC`, `HUD_TOP`
- [x] 10.6 No `hover:` on mobile-only elements — only `active:` for touch feedback
- [x] 10.7 `border-none bg-transparent` on menu/action buttons
## 11. Verification
- [x] 11.1 Desktop layout unchanged at ≥768px
- [x] 11.2 `bun run check` passes (0 errors)

View file

@ -4,12 +4,16 @@
Tauri v2 desktop shell. Cross-platform native menu bar with wired events, Developer Tools access, and `desktop/` directory structure for Tauri configuration and Rust source.
## Requirements
### Requirement: Tauri v2 desktop shell
The editor SHALL run as a native desktop app via Tauri v2 with the web frontend loaded in a webview. The app identifier SHALL be `net.dannote.open-pencil`.
The editor SHALL run as a native desktop app via Tauri v2 with the web frontend loaded in a webview. The app identifier SHALL be `net.dannote.open-pencil`. When running inside the Tauri webview, the service worker SHALL NOT be registered to avoid intercepting Tauri IPC and native file system requests.
#### Scenario: Desktop app launch
- **WHEN** user runs `bun run tauri dev`
- **THEN** a native desktop window opens with the editor UI and CanvasKit rendering
#### Scenario: No service worker in Tauri
- **WHEN** the app loads inside the Tauri webview
- **THEN** `navigator.serviceWorker.register` is never called and no SW is active
### Requirement: Native macOS menu bar
The desktop app SHALL display a native menu bar. On macOS, an app-level submenu (OpenPencil) with About, Services, Hide, Hide Others, Show All, and Quit items SHALL be shown. On Windows and Linux, this submenu SHALL be omitted. File, Edit, View, Object, Window, and Help menus SHALL be present on all platforms.

View file

@ -0,0 +1,56 @@
# pwa Specification
## Purpose
Progressive Web App support — manifest, service worker, installability, offline shell caching, auto-update strategy.
## Requirements
### Requirement: Web app manifest
The app SHALL include a `manifest.webmanifest` file linked from `index.html` with `name` "OpenPencil", `short_name` "OpenPencil", `display` "standalone", `start_url` "/", `theme_color` "#1e1e1e", `background_color` "#1e1e1e", and icon entries for 192×192, 512×512, and maskable variants.
#### Scenario: Manifest served correctly
- **WHEN** a browser requests `/manifest.webmanifest`
- **THEN** the response is a valid JSON manifest with `display: "standalone"` and at least three icon entries
#### Scenario: Installability criteria met
- **WHEN** Chrome audits the web app for PWA installability
- **THEN** all criteria pass: manifest present, service worker registered, start_url responds, icons present
### Requirement: Service worker precaching
The build SHALL generate a service worker that precaches all build output assets including JavaScript bundles, CSS, WASM files, fonts, and icons. The Workbox `maximumFileSizeToCacheInBytes` SHALL be set to at least 8MB to accommodate the CanvasKit WASM (~7MB).
#### Scenario: Assets cached on first visit
- **WHEN** a user visits the app for the first time in a browser
- **THEN** the service worker installs and all precache-manifest assets are stored in CacheStorage
#### Scenario: App shell loads offline
- **WHEN** a user has previously visited the app and then loses network connectivity
- **THEN** the app shell (HTML, JS, CSS, WASM, fonts) loads from cache and the canvas initializes
### Requirement: Auto-update strategy
The service worker SHALL use an auto-update strategy: when a new version is detected, the SW activates immediately without requiring user interaction. The `skipWaiting` and `clientsClaim` options SHALL be enabled.
#### Scenario: Transparent update on new deployment
- **WHEN** a new version is deployed and the user revisits the app
- **THEN** the new service worker activates and serves updated assets without a reload prompt
### Requirement: PWA meta tags
`index.html` SHALL include `<meta name="theme-color" content="#1e1e1e">` and `<link rel="manifest" href="/manifest.webmanifest">`. The existing `apple-touch-icon` link SHALL be preserved.
#### Scenario: Theme color matches app background
- **WHEN** the browser reads meta tags
- **THEN** `theme-color` is `#1e1e1e` matching the app's dark background
### Requirement: PWA icons
The `public/` directory SHALL contain PWA icons at 192×192 (`pwa-192.png`), 512×512 (`pwa-512.png`), and a maskable icon at 512×512 (`pwa-maskable-512.png`) with safe-zone padding.
#### Scenario: Icon sizes available
- **WHEN** the manifest is parsed
- **THEN** icons at 192×192 (purpose "any"), 512×512 (purpose "any"), and 512×512 (purpose "maskable") are referenced and the files exist
### Requirement: Vite plugin integration
`vite-plugin-pwa` SHALL be configured in `vite.config.ts` using `generateSW` mode with `autoUpdate` registration type. The plugin SHALL be added to the Vite plugins array.
#### Scenario: Build produces SW
- **WHEN** `bun run build` completes
- **THEN** `dist/sw.js` and `dist/workbox-*.js` files exist alongside the precache manifest

View file

@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: colorToCSS exported from core
The `colorToCSS` function SHALL be exported from `packages/core/src/color.ts` and compose with the existing `colorToRgba255` helper. It SHALL convert a `Color` value to a CSS `rgb(r, g, b)` string.
#### Scenario: Converts color to CSS string
- **WHEN** `colorToCSS({ r: 0.96, g: 0.26, b: 0.21, a: 1 })` is called
- **THEN** it returns `"rgb(245, 66, 54)"`
#### Scenario: Available as named import from core
- **WHEN** consumer writes `import { colorToCSS } from '@open-pencil/core'`
- **THEN** the import resolves without error and the function works as expected

View file

@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: initials utility in src/utils/text.ts
The `initials` function SHALL be exported from `src/utils/text.ts`. It SHALL take a name string and return up to 2 uppercase initials, falling back to `"?"` for empty input.
#### Scenario: Two-word name
- **WHEN** `initials("John Doe")` is called
- **THEN** it returns `"JD"`
#### Scenario: Single word
- **WHEN** `initials("Alice")` is called
- **THEN** it returns `"A"`
#### Scenario: Empty string fallback
- **WHEN** `initials("")` is called
- **THEN** it returns `"?"`

View file

@ -82,6 +82,8 @@
"typescript": "~5.8.3",
"unplugin-icons": "^23.0.1",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.0.4"
"vite": "^7.0.4",
"vite-plugin-pwa": "^1.2.0",
"workbox-window": "^7.4.0"
}
}

View file

@ -35,6 +35,11 @@ export function colorToRgba255(color: Color) {
}
}
export function colorToCSS(color: Color): string {
const { r, g, b } = colorToRgba255(color)
return `rgb(${r}, ${g}, ${b})`
}
export function rgba255ToColor(rgba: Color): Color {
return { r: rgba.r / 255, g: rgba.g / 255, b: rgba.b / 255, a: rgba.a }
}

View file

@ -68,7 +68,7 @@ export {
styleToWeight,
weightToStyle
} from './fonts'
export { parseColor, colorToHex, colorToHexRaw, colorToRgba255 } from './color'
export { parseColor, colorToHex, colorToHexRaw, colorToRgba255, colorToCSS } from './color'
export {
vectorNetworkToPath,
geometryBlobToPath,

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

BIN
public/pwa-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/pwa-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
public/pwa-maskable-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -12,9 +12,10 @@ import {
TooltipProvider
} from 'reka-ui'
import { colorToCSS } from '@open-pencil/core'
import type { CollabState, RemotePeer } from '@/composables/use-collab'
import { toast } from '@/composables/use-toast'
import type { Color } from '@/types'
import { initials } from '@/utils/text'
const props = defineProps<{
state: CollabState
@ -68,20 +69,7 @@ function onJoin() {
popoverOpen.value = false
}
function colorToCSS(c: Color): string {
return `rgb(${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)})`
}
function initials(name: string): string {
return (
name
.split(' ')
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2) || '?'
)
}
</script>
<template>

View file

@ -0,0 +1,333 @@
<script setup lang="ts">
import { nextTick, onUnmounted, ref, watch } from 'vue'
import { useEventListener } from '@vueuse/core'
import { TreeRoot, TreeItem, ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
import IconCircle from '~icons/lucide/circle'
import IconComponent from '~icons/lucide/diamond'
import IconComponentSet from '~icons/lucide/component'
import IconFrame from '~icons/lucide/frame'
import IconGroup from '~icons/lucide/group'
import IconInstance from '~icons/lucide/diamond'
import IconMinus from '~icons/lucide/minus'
import IconPenTool from '~icons/lucide/pen-tool'
import IconSection from '~icons/lucide/layout-grid'
import IconSquare from '~icons/lucide/square'
import IconType from '~icons/lucide/type'
import { useEditorStore } from '@/stores/editor'
import NodeContextMenuContent from './NodeContextMenuContent.vue'
const store = useEditorStore()
interface LayerNode {
id: string
name: string
type: string
visible: boolean
children?: LayerNode[]
}
const nodeIcons: Record<string, typeof IconSquare> = {
SECTION: IconSection,
ELLIPSE: IconCircle,
FRAME: IconFrame,
GROUP: IconGroup,
COMPONENT: IconComponent,
COMPONENT_SET: IconComponentSet,
INSTANCE: IconInstance,
LINE: IconMinus,
TEXT: IconType,
VECTOR: IconPenTool,
RECTANGLE: IconSquare
}
const COMPONENT_TYPES = new Set(['COMPONENT', 'COMPONENT_SET', 'INSTANCE'])
function buildTree(parentId: string): LayerNode[] {
const parent = store.graph.getNode(parentId)
if (!parent) return []
return parent.childIds
.map((cid) => store.graph.getNode(cid))
.filter((n): n is NonNullable<typeof n> => !!n)
.map((node) => ({
id: node.id,
name: node.name,
type: node.type,
visible: node.visible,
children: node.childIds.length > 0 ? buildTree(node.id) : undefined
}))
}
const items = ref(buildTree(store.state.currentPageId))
const treeKey = ref(0)
watch([() => store.state.sceneVersion, () => store.state.currentPageId], () => {
items.value = buildTree(store.state.currentPageId)
treeKey.value++
})
const expanded = ref<string[]>([])
watch(
() => store.state.selectedIds,
(ids) => {
const toExpand = new Set(expanded.value)
for (const id of ids) {
let node = store.graph.getNode(id)
while (node?.parentId && node.parentId !== store.state.currentPageId) {
toExpand.add(node.parentId)
node = store.graph.getNode(node.parentId)
}
}
if (toExpand.size > expanded.value.length) {
expanded.value = [...toExpand]
}
nextTick(() => {
const first = [...ids][0]
if (!first) return
const el = listRef.value?.querySelector<HTMLElement>(`[data-node-id="${first}"]`)
el?.scrollIntoView({ block: 'nearest' })
})
}
)
function onSelect(ev: CustomEvent) {
ev.preventDefault()
const node = ev.detail.value as LayerNode
if (ev.detail.originalEvent?.shiftKey) {
store.select([node.id], true)
} else {
store.select([node.id])
}
}
function onLayerRightClick(e: MouseEvent) {
const row = (e.target as HTMLElement).closest<HTMLElement>('[data-node-id]')
if (!row) return
const nodeId = row.dataset.nodeId
if (!nodeId) return
if (!store.state.selectedIds.has(nodeId)) {
store.select([nodeId])
}
}
function toggleExpand(id: string) {
const idx = expanded.value.indexOf(id)
if (idx >= 0) {
expanded.value = expanded.value.filter((e) => e !== id)
} else {
expanded.value = [...expanded.value, id]
}
}
const listRef = ref<HTMLElement | null>(null)
const dragging = ref(false)
const dragNodeId = ref<string | null>(null)
const indicatorY = ref(-1)
const indicatorDepth = ref(0)
const dropTarget = ref<{ parentId: string; index: number } | null>(null)
const dropIntoId = ref<string | null>(null)
let stopMove: (() => void) | undefined
let stopUp: (() => void) | undefined
let dragStartY = 0
let didMove = false
onUnmounted(() => {
stopMove?.()
stopUp?.()
})
function onPointerDown(e: PointerEvent, nodeId: string) {
dragStartY = e.clientY
didMove = false
dragNodeId.value = nodeId
stopMove = useEventListener(document, 'pointermove', (ev: PointerEvent) => {
if (!didMove && Math.abs(ev.clientY - dragStartY) < 4) return
didMove = true
dragging.value = true
updateDropTarget(ev)
})
stopUp = useEventListener(document, 'pointerup', () => {
if (didMove && dropTarget.value && dragNodeId.value) {
const { parentId, index } = dropTarget.value
if (parentId !== dragNodeId.value && !store.graph.isDescendant(parentId, dragNodeId.value)) {
store.graph.reorderChild(dragNodeId.value, parentId, index)
store.requestRender()
}
} else if (!didMove && dragNodeId.value) {
store.select([dragNodeId.value])
}
cleanup()
})
}
function cleanup() {
dragging.value = false
dragNodeId.value = null
indicatorY.value = -1
dropTarget.value = null
dropIntoId.value = null
stopMove?.()
stopUp?.()
}
function updateDropTarget(ev: PointerEvent) {
const list = listRef.value
if (!list || !dragNodeId.value) return
const rows = list.querySelectorAll<HTMLElement>('[data-node-id]')
const listRect = list.getBoundingClientRect()
const mouseY = ev.clientY
let bestInsertBefore: { parentId: string; index: number; y: number; depth: number } | null = null
let bestInto: { nodeId: string } | null = null
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const rowId = row.dataset.nodeId
if (!rowId) continue
if (rowId === dragNodeId.value) continue
const rect = row.getBoundingClientRect()
const rowMid = rect.top + rect.height / 2
const topZone = rect.top + rect.height * 0.25
const bottomZone = rect.top + rect.height * 0.75
const rowNode = store.graph.getNode(rowId)
if (!rowNode) continue
if (mouseY > topZone && mouseY < bottomZone && store.graph.isContainer(rowId)) {
bestInto = { nodeId: rowId }
bestInsertBefore = null
break
}
if (mouseY <= rowMid) {
const parentId = rowNode.parentId ?? store.state.currentPageId
const parent = store.graph.getNode(parentId)
if (parent) {
const idx = parent.childIds.indexOf(rowId)
const level = parseInt(row.dataset.level ?? '0')
bestInsertBefore = {
parentId,
index: Math.max(0, idx),
y: rect.top - listRect.top + list.scrollTop,
depth: level
}
}
break
}
if (i === rows.length - 1 && mouseY > rowMid) {
const parentId = rowNode.parentId ?? store.state.currentPageId
const parent = store.graph.getNode(parentId)
if (parent) {
const idx = parent.childIds.indexOf(rowId)
const level = parseInt(row.dataset.level ?? '0')
bestInsertBefore = {
parentId,
index: idx + 1,
y: rect.bottom - listRect.top + list.scrollTop,
depth: level
}
}
}
}
if (bestInto) {
dropIntoId.value = bestInto.nodeId
indicatorY.value = -1
const container = store.graph.getNode(bestInto.nodeId)
dropTarget.value = container
? { parentId: bestInto.nodeId, index: container.childIds.length }
: null
} else if (bestInsertBefore) {
dropIntoId.value = null
indicatorY.value = bestInsertBefore.y
indicatorDepth.value = bestInsertBefore.depth
dropTarget.value = { parentId: bestInsertBefore.parentId, index: bestInsertBefore.index }
} else {
dropIntoId.value = null
indicatorY.value = -1
dropTarget.value = null
}
}
</script>
<template>
<ContextMenuRoot :modal="false">
<ContextMenuTrigger as-child @contextmenu="onLayerRightClick">
<div ref="listRef" class="relative flex-1 overflow-y-auto scrollbar-thin px-1">
<TreeRoot
:key="treeKey"
v-slot="{ flattenItems }"
:items="items"
:get-key="(v: LayerNode) => v.id"
:get-children="(v: LayerNode) => v.children"
v-model:expanded="expanded"
>
<div
v-for="item in flattenItems"
:key="item._id"
:data-node-id="item.value.id"
:data-level="item.level"
>
<TreeItem v-slot="{ isExpanded }" v-bind="item.bind" as-child @select="onSelect">
<button
data-test-id="layers-item"
class="group/row flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 text-left text-xs"
:class="[
store.state.selectedIds.has(item.value.id)
? 'bg-accent text-white'
: 'bg-transparent text-surface hover:bg-hover',
dragging && dragNodeId === item.value.id ? 'opacity-30' : '',
dropIntoId === item.value.id ? 'ring-2 ring-accent ring-inset' : '',
!item.value.visible ? 'opacity-50' : ''
]"
:style="{ paddingLeft: `${8 + (item.level - 1) * 16}px` }"
@pointerdown.prevent="onPointerDown($event, item.value.id)"
>
<span
v-if="item.hasChildren"
class="flex w-4 shrink-0 cursor-pointer items-center justify-center text-muted transition-transform hover:text-surface"
:class="isExpanded ? 'rotate-90' : 'rotate-0'"
@click.stop="toggleExpand(item.value.id)"
>
<icon-lucide-chevron-right class="size-3" />
</span>
<span v-else class="w-4 shrink-0" />
<component
:is="nodeIcons[item.value.type] ?? IconSquare"
class="size-3 shrink-0"
:class="
COMPONENT_TYPES.has(item.value.type)
? 'text-[#9747ff] opacity-100'
: 'opacity-70'
"
/>
<span class="min-w-0 flex-1 truncate">{{ item.value.name }}</span>
<icon-lucide-eye-off
v-if="!item.value.visible"
class="mr-1 size-3 shrink-0 text-muted"
/>
</button>
</TreeItem>
</div>
</TreeRoot>
<div
v-if="dragging && indicatorY >= 0"
class="pointer-events-none absolute right-1 left-1 h-0.5 bg-accent"
:style="{ top: `${indicatorY}px`, marginLeft: `${indicatorDepth * 16}px` }"
/>
</div>
</ContextMenuTrigger>
<ContextMenuPortal>
<NodeContextMenuContent />
</ContextMenuPortal>
</ContextMenuRoot>
</template>

View file

@ -1,261 +1,9 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useEventListener } from '@vueuse/core'
import { TreeRoot, TreeItem, ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
import NodeContextMenuContent from './NodeContextMenuContent.vue'
import IconCircle from '~icons/lucide/circle'
import IconComponent from '~icons/lucide/diamond'
import IconComponentSet from '~icons/lucide/component'
import IconFrame from '~icons/lucide/frame'
import IconGroup from '~icons/lucide/group'
import IconInstance from '~icons/lucide/diamond'
import IconMinus from '~icons/lucide/minus'
import IconPenTool from '~icons/lucide/pen-tool'
import IconSection from '~icons/lucide/layout-grid'
import IconSquare from '~icons/lucide/square'
import IconType from '~icons/lucide/type'
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
import AppMenu from './AppMenu.vue'
import LayerTree from './LayerTree.vue'
import PagesPanel from './PagesPanel.vue'
import { useEditorStore } from '@/stores/editor'
const store = useEditorStore()
interface LayerNode {
id: string
name: string
type: string
visible: boolean
children?: LayerNode[]
}
const nodeIcons: Record<string, typeof IconSquare> = {
SECTION: IconSection,
ELLIPSE: IconCircle,
FRAME: IconFrame,
GROUP: IconGroup,
COMPONENT: IconComponent,
COMPONENT_SET: IconComponentSet,
INSTANCE: IconInstance,
LINE: IconMinus,
TEXT: IconType,
VECTOR: IconPenTool,
RECTANGLE: IconSquare
}
const COMPONENT_TYPES = new Set(['COMPONENT', 'COMPONENT_SET', 'INSTANCE'])
function buildTree(parentId: string): LayerNode[] {
const parent = store.graph.getNode(parentId)
if (!parent) return []
return parent.childIds
.map((cid) => store.graph.getNode(cid))
.filter((n): n is NonNullable<typeof n> => !!n)
.map((node) => ({
id: node.id,
name: node.name,
type: node.type,
visible: node.visible,
children: node.childIds.length > 0 ? buildTree(node.id) : undefined
}))
}
const items = ref(buildTree(store.state.currentPageId))
const treeKey = ref(0)
watch([() => store.state.sceneVersion, () => store.state.currentPageId], () => {
items.value = buildTree(store.state.currentPageId)
treeKey.value++
})
const expanded = ref<string[]>([])
watch(
() => store.state.selectedIds,
(ids) => {
const toExpand = new Set(expanded.value)
for (const id of ids) {
let node = store.graph.getNode(id)
while (node?.parentId && node.parentId !== store.state.currentPageId) {
toExpand.add(node.parentId)
node = store.graph.getNode(node.parentId)
}
}
if (toExpand.size > expanded.value.length) {
expanded.value = [...toExpand]
}
nextTick(() => {
const first = [...ids][0]
if (!first) return
const el = listRef.value?.querySelector<HTMLElement>(`[data-node-id="${first}"]`)
el?.scrollIntoView({ block: 'nearest' })
})
}
)
function onSelect(ev: CustomEvent) {
ev.preventDefault()
const node = ev.detail.value as LayerNode
if (ev.detail.originalEvent?.shiftKey) {
store.select([node.id], true)
} else {
store.select([node.id])
}
}
function onLayerRightClick(e: MouseEvent) {
const row = (e.target as HTMLElement).closest<HTMLElement>('[data-node-id]')
if (!row) return
const nodeId = row.dataset.nodeId
if (!nodeId) return
if (!store.state.selectedIds.has(nodeId)) {
store.select([nodeId])
}
}
function toggleExpand(id: string) {
const idx = expanded.value.indexOf(id)
if (idx >= 0) {
expanded.value = expanded.value.filter((e) => e !== id)
} else {
expanded.value = [...expanded.value, id]
}
}
const listRef = ref<HTMLElement | null>(null)
const dragging = ref(false)
const dragNodeId = ref<string | null>(null)
const indicatorY = ref(-1)
const indicatorDepth = ref(0)
const dropTarget = ref<{ parentId: string; index: number } | null>(null)
const dropIntoId = ref<string | null>(null)
let stopMove: (() => void) | undefined
let stopUp: (() => void) | undefined
let dragStartY = 0
let didMove = false
function onPointerDown(e: PointerEvent, nodeId: string) {
dragStartY = e.clientY
didMove = false
dragNodeId.value = nodeId
stopMove = useEventListener(document, 'pointermove', (ev: PointerEvent) => {
if (!didMove && Math.abs(ev.clientY - dragStartY) < 4) return
didMove = true
dragging.value = true
updateDropTarget(ev)
})
stopUp = useEventListener(document, 'pointerup', () => {
if (didMove && dropTarget.value && dragNodeId.value) {
const { parentId, index } = dropTarget.value
if (parentId !== dragNodeId.value && !store.graph.isDescendant(parentId, dragNodeId.value)) {
store.graph.reorderChild(dragNodeId.value, parentId, index)
store.requestRender()
}
} else if (!didMove && dragNodeId.value) {
store.select([dragNodeId.value])
}
cleanup()
})
}
function cleanup() {
dragging.value = false
dragNodeId.value = null
indicatorY.value = -1
dropTarget.value = null
dropIntoId.value = null
stopMove?.()
stopUp?.()
}
function updateDropTarget(ev: PointerEvent) {
const list = listRef.value
if (!list || !dragNodeId.value) return
const rows = list.querySelectorAll<HTMLElement>('[data-node-id]')
const listRect = list.getBoundingClientRect()
const mouseY = ev.clientY
let bestInsertBefore: { parentId: string; index: number; y: number; depth: number } | null = null
let bestInto: { nodeId: string } | null = null
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const rowId = row.dataset.nodeId
if (!rowId) continue
if (rowId === dragNodeId.value) continue
const rect = row.getBoundingClientRect()
const rowMid = rect.top + rect.height / 2
const topZone = rect.top + rect.height * 0.25
const bottomZone = rect.top + rect.height * 0.75
const rowNode = store.graph.getNode(rowId)
if (!rowNode) continue
if (mouseY > topZone && mouseY < bottomZone && store.graph.isContainer(rowId)) {
bestInto = { nodeId: rowId }
bestInsertBefore = null
break
}
if (mouseY <= rowMid) {
const parentId = rowNode.parentId ?? store.state.currentPageId
const parent = store.graph.getNode(parentId)
if (parent) {
const idx = parent.childIds.indexOf(rowId)
const level = parseInt(row.dataset.level ?? '0')
bestInsertBefore = {
parentId,
index: Math.max(0, idx),
y: rect.top - listRect.top + list.scrollTop,
depth: level
}
}
break
}
if (i === rows.length - 1 && mouseY > rowMid) {
const parentId = rowNode.parentId ?? store.state.currentPageId
const parent = store.graph.getNode(parentId)
if (parent) {
const idx = parent.childIds.indexOf(rowId)
const level = parseInt(row.dataset.level ?? '0')
bestInsertBefore = {
parentId,
index: idx + 1,
y: rect.bottom - listRect.top + list.scrollTop,
depth: level
}
}
}
}
if (bestInto) {
dropIntoId.value = bestInto.nodeId
indicatorY.value = -1
const container = store.graph.getNode(bestInto.nodeId)
dropTarget.value = container
? { parentId: bestInto.nodeId, index: container.childIds.length }
: null
} else if (bestInsertBefore) {
dropIntoId.value = null
indicatorY.value = bestInsertBefore.y
indicatorDepth.value = bestInsertBefore.depth
dropTarget.value = { parentId: bestInsertBefore.parentId, index: bestInsertBefore.index }
} else {
dropIntoId.value = null
indicatorY.value = -1
dropTarget.value = null
}
}
</script>
<template>
@ -286,82 +34,7 @@ function updateDropTarget(ev: PointerEvent) {
>
Layers
</header>
<ContextMenuRoot :modal="false">
<ContextMenuTrigger as-child @contextmenu="onLayerRightClick">
<div
ref="listRef"
data-test-id="layers-tree"
class="relative flex-1 overflow-y-auto scrollbar-thin px-1"
>
<TreeRoot
:key="treeKey"
v-slot="{ flattenItems }"
:items="items"
:get-key="(v: LayerNode) => v.id"
:get-children="(v: LayerNode) => v.children"
v-model:expanded="expanded"
>
<div
v-for="item in flattenItems"
:key="item._id"
:data-node-id="item.value.id"
:data-level="item.level"
>
<TreeItem v-slot="{ isExpanded }" v-bind="item.bind" as-child @select="onSelect">
<button
data-test-id="layers-item"
class="group/row flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 text-left text-xs"
:class="[
store.state.selectedIds.has(item.value.id)
? 'bg-accent text-white'
: 'bg-transparent text-surface hover:bg-hover',
dragging && dragNodeId === item.value.id ? 'opacity-30' : '',
dropIntoId === item.value.id ? 'ring-2 ring-accent ring-inset' : '',
!item.value.visible ? 'opacity-50' : ''
]"
:style="{ paddingLeft: `${8 + (item.level - 1) * 16}px` }"
@pointerdown.prevent="onPointerDown($event, item.value.id)"
>
<span
v-if="item.hasChildren"
class="flex w-4 shrink-0 cursor-pointer items-center justify-center text-muted transition-transform hover:text-surface"
:class="isExpanded ? 'rotate-90' : 'rotate-0'"
@click.stop="toggleExpand(item.value.id)"
>
<icon-lucide-chevron-right class="size-3" />
</span>
<span v-else class="w-4 shrink-0" />
<component
:is="nodeIcons[item.value.type] ?? IconSquare"
class="size-3 shrink-0"
:class="
COMPONENT_TYPES.has(item.value.type)
? 'text-[#9747ff] opacity-100'
: 'opacity-70'
"
/>
<span class="min-w-0 flex-1 truncate">{{ item.value.name }}</span>
<icon-lucide-eye-off
v-if="!item.value.visible"
class="mr-1 size-3 shrink-0 text-muted"
/>
</button>
</TreeItem>
</div>
</TreeRoot>
<!-- Drop indicator line -->
<div
v-if="dragging && indicatorY >= 0"
class="pointer-events-none absolute right-1 left-1 h-0.5 bg-accent"
:style="{ top: `${indicatorY}px`, marginLeft: `${indicatorDepth * 16}px` }"
/>
</div>
</ContextMenuTrigger>
<ContextMenuPortal>
<NodeContextMenuContent />
</ContextMenuPortal>
</ContextMenuRoot>
<LayerTree data-test-id="layers-tree" />
</SplitterPanel>
</SplitterGroup>
</aside>

View file

@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import ChatPanel from './ChatPanel.vue'
import CodePanel from './CodePanel.vue'
import DesignPanel from './DesignPanel.vue'
import LayerTree from './LayerTree.vue'
import PagesPanel from './PagesPanel.vue'
import { HALF_FRAC, HUD_TOP, RIBBON_H } from '@/constants'
import { useEditorStore } from '@/stores/editor'
type Snap = 'closed' | 'half' | 'full'
const store = useEditorStore()
const snap = computed({
get: (): Snap => store.state.mobileDrawerSnap,
set: (v: Snap) => {
store.state.mobileDrawerSnap = v
}
})
const visible = ref(false)
const animating = ref(false)
const renderSnap = ref<Snap>('closed')
watch(snap, async (next, prev) => {
if (next !== 'closed' && prev === 'closed') {
visible.value = true
renderSnap.value = 'closed'
await nextTick()
requestAnimationFrame(() => {
animating.value = true
renderSnap.value = next
})
} else if (next === 'closed') {
animating.value = true
renderSnap.value = 'closed'
} else {
animating.value = true
renderSnap.value = next
}
})
function onTransitionEnd() {
animating.value = false
if (renderSnap.value === 'closed') {
visible.value = false
}
}
function snapHeight(s: Snap): number {
const vh = window.innerHeight
switch (s) {
case 'full':
return vh - RIBBON_H - HUD_TOP
case 'half':
return Math.round(vh * HALF_FRAC)
default:
return Math.round(vh * HALF_FRAC)
}
}
let dragStartY = 0
let dragStartSnap: Snap = 'half'
let dragging = false
const dragOffset = ref(0)
function onDragStart(e: TouchEvent) {
dragStartY = e.touches[0].clientY
dragStartSnap = snap.value
dragging = true
dragOffset.value = 0
}
function onDragMove(e: TouchEvent) {
if (!dragging) return
dragOffset.value = e.touches[0].clientY - dragStartY
}
function onDragEnd() {
if (!dragging) return
dragging = false
const dy = dragOffset.value
dragOffset.value = 0
const THRESHOLD = 50
if (dy < -THRESHOLD) {
snap.value = dragStartSnap === 'closed' ? 'half' : 'full'
} else if (dy > THRESHOLD) {
snap.value = dragStartSnap === 'full' ? 'half' : 'closed'
if (snap.value === 'closed') {
store.state.activeRibbonTab = null
}
}
}
const drawerTransform = computed(() => {
if (dragging && dragOffset.value !== 0) {
const clamped = Math.max(0, dragOffset.value)
return `translateY(${clamped}px)`
}
return renderSnap.value === 'closed' ? 'translateY(100%)' : 'translateY(0)'
})
const drawerHeight = computed(() => `${snapHeight(renderSnap.value)}px`)
</script>
<template>
<div
v-show="visible"
data-test-id="mobile-drawer"
class="fixed inset-x-0 z-30 flex flex-col rounded-t-xl border-t border-border bg-panel"
:class="dragging ? '' : 'transition-[transform,height] duration-300 ease-out'"
:style="{
bottom: `calc(${RIBBON_H}px + env(safe-area-inset-bottom))`,
height: drawerHeight,
transform: drawerTransform
}"
@transitionend="onTransitionEnd"
@touchstart.passive="onDragStart"
@touchmove.passive="onDragMove"
@touchend.passive="onDragEnd"
>
<div
data-test-id="mobile-drawer-handle"
class="flex shrink-0 items-center justify-center py-2 select-none"
aria-hidden="true"
>
<div class="h-1 w-8 rounded-full bg-muted/40" />
</div>
<div
data-test-id="mobile-drawer-pages"
class="shrink-0 overflow-x-auto border-b border-border px-3 scrollbar-none"
>
<PagesPanel />
</div>
<div
data-test-id="mobile-drawer-content"
class="min-h-0 flex-1 overflow-y-auto"
@touchstart.stop
@touchmove.stop
>
<div
v-show="store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'layers'"
data-test-id="mobile-drawer-layers"
class="flex h-full flex-col"
>
<header class="shrink-0 px-3 py-2 text-[11px] uppercase tracking-wider text-muted">
Layers
</header>
<LayerTree />
</div>
<div
v-show="store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'design'"
data-test-id="mobile-drawer-design"
class="flex h-full flex-col"
>
<DesignPanel />
</div>
<div
v-show="store.state.activeRibbonTab === 'code'"
data-test-id="mobile-drawer-code"
class="flex h-full flex-col"
>
<CodePanel />
</div>
<div
v-show="store.state.activeRibbonTab === 'ai'"
data-test-id="mobile-drawer-ai"
class="flex h-full flex-col"
>
<ChatPanel />
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
PopoverRoot,
PopoverTrigger,
PopoverPortal,
PopoverContent,
DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuContent,
DropdownMenuItem
} from 'reka-ui'
import IconFilePlus from '~icons/lucide/file-plus'
import IconFolderOpen from '~icons/lucide/folder-open'
import IconImageDown from '~icons/lucide/image-down'
import IconSave from '~icons/lucide/save'
import IconZoomIn from '~icons/lucide/zoom-in'
import { openFileDialog } from '@/composables/use-menu'
import { useEditorStore } from '@/stores/editor'
import { colorToCSS } from '@open-pencil/core'
import { toolIcons } from '@/utils/tools'
import { initials } from '@/utils/text'
import type { Component } from 'vue'
import type { CollabState, RemotePeer } from '@/composables/use-collab'
const props = defineProps<{
collabState: CollabState
collabPeers: RemotePeer[]
pendingRoomId?: string | null
followingPeer?: number | null
}>()
const emit = defineEmits<{
share: []
join: [roomId: string]
disconnect: []
'update:collab-name': [name: string]
follow: [clientId: number | null]
}>()
const store = useEditorStore()
const activeToolIcon = computed(() => toolIcons[store.state.activeTool])
interface MenuAction {
icon: Component
label: string
action: () => void
}
const menuItems: MenuAction[] = [
{
icon: IconFilePlus,
label: 'New',
action: () => import('@/stores/tabs').then((m) => m.createTab())
},
{ icon: IconFolderOpen, label: 'Open…', action: () => openFileDialog() },
{ icon: IconSave, label: 'Save', action: () => store.saveFigFile() },
{
icon: IconImageDown,
label: 'Export…',
action: () => store.exportSelection(1, 'PNG')
},
{ icon: IconZoomIn, label: 'Zoom to fit', action: () => store.zoomToFit() }
]
const onlineCount = computed(() => props.collabPeers.length + 1)
</script>
<template>
<div
class="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-start px-3 pt-3"
@touchstart.stop
>
<!-- Undo / Redo + active tool indicator -->
<div class="pointer-events-auto flex flex-col items-start gap-1.5">
<div class="flex gap-1.5">
<button
class="flex size-8 cursor-pointer items-center justify-center rounded-full border border-border bg-panel shadow-md select-none active:bg-hover"
title="Undo"
@click="store.undoAction()"
>
<icon-lucide-undo-2 class="size-3.5 text-surface" />
</button>
<button
class="flex size-8 cursor-pointer items-center justify-center rounded-full border border-border bg-panel shadow-md select-none active:bg-hover"
title="Redo"
@click="store.redoAction()"
>
<icon-lucide-redo-2 class="size-3.5 text-surface" />
</button>
</div>
<div
class="flex size-8 items-center justify-center rounded-full border border-accent/30 bg-panel shadow-md transition-colors duration-200"
>
<Transition
mode="out-in"
enter-active-class="animate-in fade-in zoom-in-75 duration-150"
leave-active-class="animate-out fade-out zoom-out-75 duration-150"
>
<component
:is="activeToolIcon"
:key="store.state.activeTool"
class="size-3.5 text-accent"
/>
</Transition>
</div>
</div>
<!-- Center: Online badge + action toast -->
<div class="pointer-events-auto relative mx-auto flex flex-col items-center gap-1.5">
<!-- Online badge with peers popover -->
<PopoverRoot v-if="props.collabState.connected">
<PopoverTrigger as-child>
<button
class="flex h-8 cursor-pointer items-center gap-1.5 rounded-full border border-border bg-panel px-3 shadow-md select-none active:bg-hover"
>
<span class="size-2 rounded-full bg-green-500" />
<span class="text-xs text-surface">Online: {{ onlineCount }}</span>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
:modal="false"
:side-offset="8"
side="bottom"
align="center"
class="z-50 w-56 rounded-xl border border-border bg-panel p-3 shadow-xl"
>
<div class="mb-2 text-[11px] uppercase tracking-wider text-muted">In this room</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div
class="flex size-7 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
:style="{ background: colorToCSS(props.collabState.localColor) }"
>
{{ initials(props.collabState.localName || 'You') }}
</div>
<span class="min-w-0 flex-1 truncate text-xs text-surface">
{{ props.collabState.localName || 'You' }}
</span>
<span class="text-[10px] text-muted">you</span>
</div>
<div
v-for="peer in props.collabPeers"
:key="peer.clientId"
class="flex cursor-pointer items-center gap-2 rounded-md px-0.5 py-0.5 select-none active:bg-hover"
@click="emit('follow', props.followingPeer === peer.clientId ? null : peer.clientId)"
>
<div
class="flex size-7 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
:class="props.followingPeer === peer.clientId ? 'ring-2 ring-white/40' : ''"
:style="{ background: colorToCSS(peer.color) }"
>
{{ initials(peer.name) }}
</div>
<span class="min-w-0 flex-1 truncate text-xs text-surface">{{ peer.name }}</span>
<span v-if="props.followingPeer === peer.clientId" class="text-[10px] text-accent"
>following</span
>
</div>
</div>
<button
class="mt-3 flex h-7 w-full cursor-pointer items-center justify-center rounded border border-border bg-transparent text-xs text-muted select-none active:bg-hover"
@click="emit('disconnect')"
>
Disconnect
</button>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<!-- Action toast -->
<Transition enter-active-class="animate-in fade-in duration-150" leave-active-class="animate-out fade-out duration-200">
<div
v-if="store.state.actionToast"
:key="store.state.actionToast"
class="flex h-8 items-center rounded-full border border-accent/30 bg-panel px-3 shadow-md"
>
<span class="whitespace-nowrap text-xs text-accent">{{ store.state.actionToast }}</span>
</div>
</Transition>
</div>
<!-- Share + Menu -->
<div class="pointer-events-auto flex items-center gap-1.5">
<button
class="flex h-8 cursor-pointer items-center gap-1.5 rounded-full border border-border bg-panel px-3 shadow-md select-none active:bg-hover"
@click="emit('share')"
>
<icon-lucide-share-2 class="size-3.5 text-surface" />
<span class="text-xs text-surface">Share</span>
</button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
class="flex size-8 cursor-pointer items-center justify-center rounded-full border border-border bg-panel shadow-md select-none active:bg-hover"
>
<icon-lucide-menu class="size-3.5 text-surface" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:side-offset="8"
side="bottom"
align="end"
class="z-50 w-48 rounded-xl border border-border bg-panel p-1.5 shadow-xl"
>
<DropdownMenuItem
v-for="item in menuItems"
:key="item.label"
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-none bg-transparent px-2.5 py-2 text-xs text-surface outline-none select-none active:bg-hover data-[highlighted]:bg-hover"
@click="item.action()"
>
<component :is="item.icon" class="size-4 text-muted" />
<span>{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
</div>
</template>

View file

@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed } from 'vue'
import { SWIPE_MAX_DURATION, SWIPE_THRESHOLD } from '@/constants'
import { useEditorStore } from '@/stores/editor'
const store = useEditorStore()
const emit = defineEmits<{
'tab-change': [tab: 'panels' | 'code' | 'ai']
}>()
const isLayersActive = computed(
() => store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'layers'
)
const isDesignActive = computed(
() => store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'design'
)
function selectTab(tab: 'panels' | 'code' | 'ai') {
if (store.state.activeRibbonTab === tab && store.state.mobileDrawerSnap !== 'closed') {
store.state.activeRibbonTab = null
store.state.mobileDrawerSnap = 'closed'
return
}
store.state.activeRibbonTab = tab
emit('tab-change', tab)
}
function selectPanel(mode: 'layers' | 'design') {
if (
store.state.activeRibbonTab === 'panels' &&
store.state.panelMode === mode &&
store.state.mobileDrawerSnap !== 'closed'
) {
store.state.activeRibbonTab = null
store.state.mobileDrawerSnap = 'closed'
return
}
store.state.activeRibbonTab = 'panels'
store.state.panelMode = mode
emit('tab-change', 'panels')
}
let touchStartY = 0
let touchStartTime = 0
function onRibbonTouchStart(e: TouchEvent) {
touchStartY = e.touches[0].clientY
touchStartTime = Date.now()
}
function onRibbonTouchEnd(e: TouchEvent) {
const dy = e.changedTouches[0].clientY - touchStartY
const dt = Date.now() - touchStartTime
if (dt > SWIPE_MAX_DURATION) return
if (dy < -SWIPE_THRESHOLD) {
if (store.state.mobileDrawerSnap === 'closed') {
if (!store.state.activeRibbonTab) store.state.activeRibbonTab = 'panels'
store.state.mobileDrawerSnap = 'half'
} else {
store.state.mobileDrawerSnap = 'full'
}
} else if (dy > SWIPE_THRESHOLD) {
store.state.mobileDrawerSnap = 'closed'
store.state.activeRibbonTab = null
}
}
</script>
<template>
<nav
aria-label="Mobile panel navigation"
data-test-id="mobile-ribbon"
class="pointer-events-auto fixed inset-x-0 bottom-0 z-40 flex h-11 items-center border-t border-border bg-panel"
role="tablist"
style="padding-bottom: env(safe-area-inset-bottom)"
@touchstart.passive="onRibbonTouchStart"
@touchend.passive="onRibbonTouchEnd"
>
<div
role="tab"
data-test-id="mobile-ribbon-layers"
:aria-selected="isLayersActive"
tabindex="0"
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs outline-none transition-colors select-none"
:class="isLayersActive ? 'text-accent' : 'text-muted'"
@click="selectPanel('layers')"
>
<icon-lucide-layers class="size-4" />
<span v-show="isLayersActive">Layers</span>
</div>
<div
role="tab"
data-test-id="mobile-ribbon-design"
:aria-selected="isDesignActive"
tabindex="0"
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs outline-none transition-colors select-none"
:class="isDesignActive ? 'text-accent' : 'text-muted'"
@click="selectPanel('design')"
>
<icon-lucide-sliders-horizontal class="size-4" />
<span v-show="isDesignActive">Design</span>
</div>
<div class="flex-1" />
<div
role="tab"
data-test-id="mobile-ribbon-code"
:aria-selected="store.state.activeRibbonTab === 'code'"
tabindex="0"
class="flex h-full cursor-pointer items-center justify-center px-3 outline-none transition-colors select-none"
:class="store.state.activeRibbonTab === 'code' ? 'text-accent' : 'text-muted'"
@click="selectTab('code')"
>
<icon-lucide-code class="size-4" />
</div>
<div
role="tab"
data-test-id="mobile-ribbon-ai"
:aria-selected="store.state.activeRibbonTab === 'ai'"
tabindex="0"
class="flex h-full cursor-pointer items-center justify-center px-3 outline-none transition-colors select-none"
:class="store.state.activeRibbonTab === 'ai' ? 'text-accent' : 'text-muted'"
@click="selectTab('ai')"
>
<icon-lucide-sparkles class="size-4" />
</div>
</nav>
</template>

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import {
DropdownMenuRoot,
DropdownMenuTrigger,
@ -6,39 +7,32 @@ import {
DropdownMenuItem,
DropdownMenuPortal
} from 'reka-ui'
import { useBreakpoints } from '@vueuse/core'
import IconMousePointer from '~icons/lucide/mouse-pointer'
import IconFrame from '~icons/lucide/frame'
import IconLayoutGrid from '~icons/lucide/layout-grid'
import IconSquare from '~icons/lucide/square'
import IconCircle from '~icons/lucide/circle'
import IconMinus from '~icons/lucide/minus'
import IconTriangle from '~icons/lucide/triangle'
import IconStar from '~icons/lucide/star'
import IconPenTool from '~icons/lucide/pen-tool'
import IconType from '~icons/lucide/type'
import IconHand from '~icons/lucide/hand'
import IconChevronDown from '~icons/lucide/chevron-down'
import IconChevronLeft from '~icons/lucide/chevron-left'
import IconChevronRight from '~icons/lucide/chevron-right'
import IconCopy from '~icons/lucide/copy'
import IconClipboard from '~icons/lucide/clipboard'
import IconScissors from '~icons/lucide/scissors'
import IconCopyPlus from '~icons/lucide/copy-plus'
import IconTrash2 from '~icons/lucide/trash-2'
import IconArrowUpToLine from '~icons/lucide/arrow-up-to-line'
import IconArrowDownToLine from '~icons/lucide/arrow-down-to-line'
import IconGroup from '~icons/lucide/group'
import IconUngroup from '~icons/lucide/ungroup'
import IconLock from '~icons/lucide/lock'
import { ACTION_TOAST_DURATION } from '@/constants'
import { TOOLS, useEditorStore } from '@/stores/editor'
import { toolIcons } from '@/utils/tools'
import type { Component } from 'vue'
import type { Tool } from '@/stores/editor'
const store = useEditorStore()
const toolIcons: Record<Tool, typeof IconSquare> = {
SELECT: IconMousePointer,
FRAME: IconFrame,
SECTION: IconLayoutGrid,
RECTANGLE: IconSquare,
ELLIPSE: IconCircle,
LINE: IconMinus,
POLYGON: IconTriangle,
STAR: IconStar,
PEN: IconPenTool,
TEXT: IconType,
HAND: IconHand
}
const breakpoints = useBreakpoints({ mobile: 768 })
const isMobile = breakpoints.smaller('mobile')
const toolLabels: Record<Tool, string> = {
SELECT: 'Move',
@ -77,16 +71,86 @@ function activeKeyForTool(tool: (typeof TOOLS)[number]): Tool {
if (tool.flyout?.includes(store.state.activeTool)) return store.state.activeTool
return tool.key
}
interface ActionItem {
icon: Component
label: string
action: () => void
}
const editActions: ActionItem[] = [
{ icon: IconCopy, label: 'Copy', action: () => store.mobileCopy() },
{ icon: IconClipboard, label: 'Paste', action: () => store.mobilePaste() },
{ icon: IconScissors, label: 'Cut', action: () => store.mobileCut() },
{ icon: IconCopyPlus, label: 'Duplicate', action: () => store.duplicateSelected() },
{ icon: IconTrash2, label: 'Delete', action: () => store.deleteSelected() }
]
const arrangeActions: ActionItem[] = [
{ icon: IconArrowUpToLine, label: 'Front', action: () => store.bringToFront() },
{ icon: IconArrowDownToLine, label: 'Back', action: () => store.sendToBack() },
{ icon: IconGroup, label: 'Group', action: () => store.groupSelected() },
{ icon: IconUngroup, label: 'Ungroup', action: () => store.ungroupSelected() },
{ icon: IconLock, label: 'Lock', action: () => store.toggleLock() }
]
const CATEGORY_COUNT = 3
const mobileCategory = ref(0)
const hasPrev = computed(() => mobileCategory.value > 0)
const hasNext = computed(() => mobileCategory.value < CATEGORY_COUNT - 1)
function prevCategory() {
if (hasPrev.value) mobileCategory.value--
}
function nextCategory() {
if (hasNext.value) mobileCategory.value++
}
let toastTimer: ReturnType<typeof setTimeout> | undefined
function onActionTap(item: ActionItem) {
item.action()
store.state.actionToast = item.label
clearTimeout(toastTimer)
toastTimer = setTimeout(() => {
store.state.actionToast = null
}, ACTION_TOAST_DURATION)
}
const cat0Ref = ref<HTMLElement | null>(null)
const cat1Ref = ref<HTMLElement | null>(null)
const cat2Ref = ref<HTMLElement | null>(null)
const catRefs = [cat0Ref, cat1Ref, cat2Ref]
const wrapperW = ref(0)
const wrapperH = ref(0)
const measured = ref(false)
function measure() {
const el = catRefs[mobileCategory.value]?.value
if (el) {
wrapperW.value = el.scrollWidth
wrapperH.value = el.scrollHeight
measured.value = true
}
}
onMounted(() => {
nextTick(measure)
})
watch(mobileCategory, () => {
nextTick(measure)
})
</script>
<template>
<div class="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center">
<div v-if="!isMobile" class="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center">
<div
data-test-id="toolbar"
class="flex gap-0.5 rounded-xl border border-border bg-panel p-1 shadow-lg"
>
<template v-for="tool in TOOLS" :key="tool.key">
<!-- Tool with flyout: split button + chevron -->
<div v-if="tool.flyout && tool.flyout.length > 1" class="flex items-center">
<button
:data-test-id="`toolbar-tool-${activeKeyForTool(tool).toLowerCase()}`"
@ -147,7 +211,6 @@ function activeKeyForTool(tool: (typeof TOOLS)[number]): Tool {
</DropdownMenuRoot>
</div>
<!-- Simple tool button -->
<button
v-else
:data-test-id="`toolbar-tool-${tool.key.toLowerCase()}`"
@ -165,4 +228,160 @@ function activeKeyForTool(tool: (typeof TOOLS)[number]): Tool {
</template>
</div>
</div>
<!-- Mobile toolbar -->
<div
v-else
data-test-id="mobile-toolbar"
class="fixed inset-x-0 z-20 flex items-center justify-center gap-1.5 px-2"
:style="{ bottom: `calc(44px + env(safe-area-inset-bottom) + 0.75rem)` }"
@touchstart.stop
>
<button
data-test-id="mobile-toolbar-prev"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-panel shadow-sm transition-opacity select-none"
:class="hasPrev ? 'text-muted opacity-100' : 'pointer-events-none opacity-0'"
@click="prevCategory"
>
<IconChevronLeft class="size-3.5" />
</button>
<div
data-test-id="mobile-toolbar-container"
class="relative flex h-11 items-center overflow-hidden rounded-[8px] border border-border bg-panel px-2 shadow-lg"
:style="measured ? { width: wrapperW + 16 + 'px', transition: 'width 250ms ease' } : {}"
>
<div
ref="cat0Ref"
data-test-id="mobile-toolbar-tools"
class="flex gap-0.5 transition-opacity duration-200"
:class="
mobileCategory === 0
? 'relative opacity-100'
: 'absolute left-2 top-1/2 -translate-y-1/2 pointer-events-none opacity-0'
"
>
<template v-for="tool in TOOLS" :key="tool.key">
<div v-if="tool.flyout && tool.flyout.length > 1" class="flex items-center">
<button
:data-test-id="`mobile-toolbar-tool-${activeKeyForTool(tool).toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none transition-colors select-none"
:class="
isActive(tool)
? 'bg-accent text-white'
: 'bg-transparent text-muted active:bg-hover'
"
@click="store.setTool(activeKeyForTool(tool))"
>
<component :is="toolIcons[activeKeyForTool(tool)]" class="size-4" />
</button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
:data-test-id="`mobile-toolbar-flyout-${tool.key.toLowerCase()}`"
class="flex h-8 w-3 cursor-pointer items-center justify-center rounded-[6px] border-none transition-colors select-none"
:class="
isActive(tool)
? 'bg-accent text-white'
: 'bg-transparent text-muted active:bg-hover'
"
>
<IconChevronDown class="size-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="top"
:side-offset="8"
align="start"
class="min-w-32 rounded-lg border border-border bg-panel p-1 shadow-lg"
>
<DropdownMenuItem
v-for="sub in tool.flyout"
:key="sub"
:data-test-id="`mobile-toolbar-flyout-item-${sub.toLowerCase()}`"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none transition-colors"
:class="
store.state.activeTool === sub
? 'bg-accent text-white'
: 'text-surface hover:bg-hover'
"
@select="store.setTool(sub)"
>
<component :is="toolIcons[sub]" class="size-3.5" />
<span class="flex-1">{{ toolLabels[sub] }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<button
v-else
:data-test-id="`mobile-toolbar-tool-${tool.key.toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none transition-colors select-none"
:class="
isActive(tool) ? 'bg-accent text-white' : 'bg-transparent text-muted active:bg-hover'
"
@click="store.setTool(tool.key)"
>
<component :is="toolIcons[tool.key]" class="size-4" />
</button>
</template>
</div>
<div
ref="cat1Ref"
data-test-id="mobile-toolbar-edit"
class="flex gap-0.5 transition-opacity duration-200"
:class="
mobileCategory === 1
? 'relative opacity-100'
: 'absolute left-2 top-1/2 -translate-y-1/2 pointer-events-none opacity-0'
"
>
<button
v-for="item in editActions"
:key="item.label"
:data-test-id="`mobile-toolbar-${item.label.toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none bg-transparent text-muted transition-colors select-none active:bg-hover active:text-surface"
@click="onActionTap(item)"
>
<component :is="item.icon" class="size-4" />
</button>
</div>
<div
ref="cat2Ref"
data-test-id="mobile-toolbar-arrange"
class="flex gap-0.5 transition-opacity duration-200"
:class="
mobileCategory === 2
? 'relative opacity-100'
: 'absolute left-2 top-1/2 -translate-y-1/2 pointer-events-none opacity-0'
"
>
<button
v-for="item in arrangeActions"
:key="item.label"
:data-test-id="`mobile-toolbar-${item.label.toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none bg-transparent text-muted transition-colors select-none active:bg-hover active:text-surface"
@click="onActionTap(item)"
>
<component :is="item.icon" class="size-4" />
</button>
</div>
</div>
<button
data-test-id="mobile-toolbar-next"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-panel shadow-sm transition-opacity select-none"
:class="hasNext ? 'text-muted opacity-100' : 'pointer-events-none opacity-0'"
@click="nextCategory"
>
<IconChevronRight class="size-3.5" />
</button>
</div>
</template>

View file

@ -1092,8 +1092,6 @@ export function useCanvasInput(
})
}
// Touch support for iOS/mobile: single-finger pan, two-finger pinch-zoom
const isTouchDevice = matchMedia('(pointer: coarse)').matches
let activeTouches: Touch[] = []
let pinchStartDist = 0
let pinchStartZoom = 0
@ -1104,14 +1102,31 @@ export function useCanvasInput(
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY)
}
let touchAsMouse = false
function syntheticMouse(type: string, t: Touch): MouseEvent {
return new MouseEvent(type, {
clientX: t.clientX,
clientY: t.clientY,
screenX: t.screenX,
screenY: t.screenY,
button: 0,
buttons: 1,
bubbles: true
})
}
function onTouchStart(e: TouchEvent) {
if (!isTouchDevice) return
e.preventDefault()
activeTouches = Array.from(e.touches)
const canvas = canvasRef.value
if (!canvas) return
if (activeTouches.length === 2) {
if (touchAsMouse) {
onMouseUp()
touchAsMouse = false
}
drag.value = null
const [a, b] = activeTouches
pinchStartDist = touchDist(a, b)
@ -1121,18 +1136,24 @@ export function useCanvasInput(
pinchMidY = (a.clientY + b.clientY) / 2 - rect.top
} else if (activeTouches.length === 1) {
const t = activeTouches[0]
drag.value = {
type: 'pan',
startScreenX: t.clientX,
startScreenY: t.clientY,
startPanX: store.state.panX,
startPanY: store.state.panY
const tool = store.state.activeTool
if (tool === 'HAND') {
touchAsMouse = false
drag.value = {
type: 'pan',
startScreenX: t.clientX,
startScreenY: t.clientY,
startPanX: store.state.panX,
startPanY: store.state.panY
}
} else {
touchAsMouse = true
onMouseDown(syntheticMouse('mousedown', t))
}
}
}
function onTouchMove(e: TouchEvent) {
if (!isTouchDevice) return
e.preventDefault()
activeTouches = Array.from(e.touches)
const canvas = canvasRef.value
@ -1162,31 +1183,41 @@ export function useCanvasInput(
pinchMidX = newMidX
pinchMidY = newMidY
store.requestRepaint()
} else if (activeTouches.length === 1 && drag.value?.type === 'pan') {
} else if (activeTouches.length === 1) {
const t = activeTouches[0]
const d = drag.value
store.state.panX = d.startPanX + (t.clientX - d.startScreenX)
store.state.panY = d.startPanY + (t.clientY - d.startScreenY)
store.requestRepaint()
if (touchAsMouse) {
onMouseMove(syntheticMouse('mousemove', t))
} else if (drag.value?.type === 'pan') {
const d = drag.value
store.state.panX = d.startPanX + (t.clientX - d.startScreenX)
store.state.panY = d.startPanY + (t.clientY - d.startScreenY)
store.requestRepaint()
}
}
}
function onTouchEnd(e: TouchEvent) {
if (!isTouchDevice) return
e.preventDefault()
activeTouches = Array.from(e.touches)
if (activeTouches.length === 0) {
drag.value = null
if (touchAsMouse) {
onMouseUp()
touchAsMouse = false
} else {
drag.value = null
}
pinchStartDist = 0
} else if (activeTouches.length === 1) {
const t = activeTouches[0]
drag.value = {
type: 'pan',
startScreenX: t.clientX,
startScreenY: t.clientY,
startPanX: store.state.panX,
startPanY: store.state.panY
if (!touchAsMouse) {
drag.value = {
type: 'pan',
startScreenX: t.clientX,
startScreenY: t.clientY,
startPanX: store.state.panX,
startPanY: store.state.panY
}
}
pinchStartDist = 0
}

View file

@ -127,7 +127,13 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
}
const params = new URLSearchParams(window.location.search)
const showRulers = !params.has('no-rulers')
const noRulersParam = params.has('no-rulers')
const mobileQuery = matchMedia('(max-width: 767px)')
let showRulers = !noRulersParam && !mobileQuery.matches
mobileQuery.addEventListener('change', (e) => {
showRulers = !noRulersParam && !e.matches
dirty = true
})
function renderNow() {
if (!renderer || destroyed) return

View file

@ -1,4 +1,4 @@
import { useEventListener } from '@vueuse/core'
import { useBreakpoints, useEventListener } from '@vueuse/core'
import { useAIChat } from '@/composables/use-chat'
import { TOOL_SHORTCUTS, useEditorStore } from '@/stores/editor'
@ -13,6 +13,8 @@ function isEditing(e: Event) {
export function useKeyboard() {
const { activeTab } = useAIChat()
const store = useEditorStore()
const breakpoints = useBreakpoints({ mobile: 768 })
const isMobile = breakpoints.smaller('mobile')
useEventListener(window, 'copy', (e: ClipboardEvent) => {
if (isEditing(e)) return
@ -89,7 +91,14 @@ export function useKeyboard() {
}
if (e.code === 'KeyJ') {
e.preventDefault()
activeTab.value = activeTab.value === 'ai' ? 'design' : 'ai'
if (isMobile.value) {
store.state.activeRibbonTab = store.state.activeRibbonTab === 'ai' ? 'panels' : 'ai'
if (store.state.mobileDrawerSnap === 'closed') {
store.state.mobileDrawerSnap = 'half'
}
} else {
activeTab.value = activeTab.value === 'ai' ? 'design' : 'ai'
}
return
}
if (e.key === 'w') {

View file

@ -101,6 +101,15 @@ export const DEFAULT_FRAME_FILL: Fill = {
}
export const HANDLE_SIZE = 6
export const RIBBON_H = 44
export const HALF_FRAC = 3 / 7
export const HUD_TOP = 12 + 32 + 6 + 32 + 12
export const SWIPE_THRESHOLD = 30
export const SWIPE_MAX_DURATION = 500
export const ACTION_TOAST_DURATION = 800
export const DRAG_DEAD_ZONE = 4
export const PEN_CLOSE_THRESHOLD = 8
export const ROTATION_SNAP_DEGREES = 15

1
src/env.d.ts vendored
View file

@ -1,4 +1,5 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/vanillajs" />
/// <reference types="unplugin-icons/types/vue" />
declare module '*.vue' {

View file

@ -2,9 +2,16 @@ import { createApp } from 'vue'
import './app.css'
import { preloadFonts } from '@/engine/fonts'
import { IS_TAURI } from '@/constants'
import App from './App.vue'
import router from './router'
preloadFonts()
createApp(App).use(router).mount('#app')
if (!IS_TAURI) {
import('virtual:pwa-register').then(({ registerSW }) => {
registerSW({ immediate: true })
})
}

View file

@ -182,7 +182,7 @@ export function createEditorStore() {
y: number
selection?: string[]
}>,
showUI: matchMedia('(min-width: 768px)').matches,
showUI: true,
documentName: 'Untitled' as string,
panX: 0,
pageColor: { ...CANVAS_BG_COLOR } as Color,
@ -191,6 +191,11 @@ export function createEditorStore() {
renderVersion: 0,
sceneVersion: 0,
loading: false,
activeRibbonTab: null as 'panels' | 'code' | 'ai' | null,
panelMode: 'design' as 'layers' | 'design',
actionToast: null as string | null,
mobileDrawerSnap: 'closed' as 'closed' | 'half' | 'full',
clipboardHtml: '',
autosaveEnabled: true
})
@ -1897,6 +1902,23 @@ export function createEditorStore() {
requestRender()
}
function mobileCopy() {
const transfer = new DataTransfer()
writeCopyData(transfer)
state.clipboardHtml = transfer.getData('text/html')
}
function mobileCut() {
mobileCopy()
deleteSelected()
}
function mobilePaste() {
if (state.clipboardHtml) {
pasteFromHTML(state.clipboardHtml)
}
}
function commitMove(originals: Map<string, { x: number; y: number }>) {
const finals = new Map<string, { x: number; y: number }>()
for (const [id] of originals) {
@ -2116,6 +2138,9 @@ export function createEditorStore() {
duplicateSelected,
writeCopyData,
pasteFromHTML,
mobileCopy,
mobileCut,
mobilePaste,
deleteSelected,
commitMove,
commitResize,

10
src/utils/text.ts Normal file
View file

@ -0,0 +1,10 @@
export function initials(name: string): string {
return (
name
.split(' ')
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2) || '?'
)
}

28
src/utils/tools.ts Normal file
View file

@ -0,0 +1,28 @@
import IconCircle from '~icons/lucide/circle'
import IconFrame from '~icons/lucide/frame'
import IconHand from '~icons/lucide/hand'
import IconLayoutGrid from '~icons/lucide/layout-grid'
import IconMinus from '~icons/lucide/minus'
import IconMousePointer from '~icons/lucide/mouse-pointer'
import IconPenTool from '~icons/lucide/pen-tool'
import IconSquare from '~icons/lucide/square'
import IconStar from '~icons/lucide/star'
import IconTriangle from '~icons/lucide/triangle'
import IconType from '~icons/lucide/type'
import type { Component } from 'vue'
import type { Tool } from '@/stores/editor'
export const toolIcons: Record<Tool, Component> = {
SELECT: IconMousePointer,
FRAME: IconFrame,
SECTION: IconLayoutGrid,
RECTANGLE: IconSquare,
ELLIPSE: IconCircle,
LINE: IconMinus,
POLYGON: IconTriangle,
STAR: IconStar,
PEN: IconPenTool,
TEXT: IconType,
HAND: IconHand
}

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { provide } from 'vue'
import { useEventListener, useUrlSearchParams } from '@vueuse/core'
import { useBreakpoints, useEventListener, useUrlSearchParams } from '@vueuse/core'
import { useRoute, useRouter } from 'vue-router'
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
@ -15,6 +15,9 @@ import { createTab, activeTab } from '@/stores/tabs'
import CollabPanel from '@/components/CollabPanel.vue'
import EditorCanvas from '@/components/EditorCanvas.vue'
import LayersPanel from '@/components/LayersPanel.vue'
import MobileDrawer from '@/components/MobileDrawer.vue'
import MobileHud from '@/components/MobileHud.vue'
import MobileRibbon from '@/components/MobileRibbon.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue'
import SafariBanner from '@/components/SafariBanner.vue'
import TabBar from '@/components/TabBar.vue'
@ -25,6 +28,8 @@ const router = useRouter()
const firstTab = createTab()
const store = useEditorStore()
const breakpoints = useBreakpoints({ mobile: 768 })
const isMobile = breakpoints.smaller('mobile')
useKeyboard()
useMenu()
const collab = useCollab(firstTab.store)
@ -63,14 +68,22 @@ function onDisconnect() {
collab.disconnect()
router.push('/')
}
function onMobileTabChange() {
if (store.state.mobileDrawerSnap === 'closed') {
store.state.mobileDrawerSnap = 'half'
}
}
</script>
<template>
<div data-test-id="editor-root" class="flex h-screen w-screen flex-col">
<SafariBanner />
<TabBar />
<!-- Desktop layout -->
<SplitterGroup
v-if="showChrome && store.state.showUI"
v-if="!isMobile && showChrome && store.state.showUI"
:key="activeTab?.id"
direction="horizontal"
class="flex-1 overflow-hidden"
@ -110,6 +123,33 @@ function onDisconnect() {
<PropertiesPanel />
</SplitterPanel>
</SplitterGroup>
<!-- Mobile layout -->
<div
v-else-if="isMobile && showChrome && store.state.showUI"
:key="'mobile-' + activeTab?.id"
class="flex flex-1 overflow-hidden"
>
<div class="relative flex min-w-0 flex-1">
<EditorCanvas />
<MobileHud
:collab-state="collab.state.value"
:collab-peers="collab.remotePeers.value"
:pending-room-id="pendingRoomId"
:following-peer="collab.followingPeer.value"
@share="onShare"
@join="onJoin"
@disconnect="onDisconnect"
@update:collab-name="collab.setLocalName"
@follow="collab.followPeer"
/>
<Toolbar />
</div>
<MobileRibbon @tab-change="onMobileTabChange" />
<MobileDrawer />
</div>
<!-- Collapsed UI (showUI=false) -->
<div
v-else-if="showChrome"
:key="'collapsed-' + activeTab?.id"
@ -117,8 +157,8 @@ function onDisconnect() {
>
<div class="relative flex min-w-0 flex-1">
<EditorCanvas />
<Toolbar />
<div
v-if="!isMobile"
class="absolute left-7 top-7 z-10 flex items-center gap-2 rounded-lg border border-border bg-panel px-2 py-1 shadow-sm"
>
<img src="/favicon-32.png" class="size-4" alt="OpenPencil" />
@ -136,6 +176,8 @@ function onDisconnect() {
</div>
</div>
</div>
<!-- Bare canvas (no chrome, e.g. ?no-chrome) -->
<div v-else :key="'bare-' + activeTab?.id" class="flex flex-1 overflow-hidden">
<div class="relative flex min-w-0 flex-1">
<EditorCanvas />

View file

@ -6,6 +6,7 @@ import tailwindcss from '@tailwindcss/vite'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'
import Components from 'unplugin-vue-components/vite'
import { VitePWA } from 'vite-plugin-pwa'
import { copyFileSync, existsSync, mkdirSync } from 'fs'
// @ts-expect-error process is a nodejs global
@ -47,7 +48,33 @@ export default defineConfig(async () => ({
tailwindcss(),
Icons({ compiler: 'vue3' }),
Components({ resolvers: [IconsResolver({ prefix: 'icon' })] }),
vue()
vue(),
VitePWA({
registerType: 'autoUpdate',
devOptions: { enabled: false },
workbox: {
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,wasm,png,ico,ttf,webmanifest}'],
navigateFallback: '/index.html'
},
manifest: {
name: 'OpenPencil',
short_name: 'OpenPencil',
description: 'Open-source design editor',
display: 'standalone',
orientation: 'any',
start_url: '/',
scope: '/',
theme_color: '#1e1e1e',
background_color: '#1e1e1e',
categories: ['design', 'productivity'],
icons: [
{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/pwa-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
}
})
],
clearScreen: false,
server: {