- 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:
parent
661b83e749
commit
168c25c189
52
.github/workflows/ci.yml
vendored
52
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
9
components.d.ts
vendored
9
components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-03
|
||||
61
openspec/changes/archive/2026-03-03-pwa-support/design.md
Normal file
61
openspec/changes/archive/2026-03-03-pwa-support/design.md
Normal 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.
|
||||
30
openspec/changes/archive/2026-03-03-pwa-support/proposal.md
Normal file
30
openspec/changes/archive/2026-03-03-pwa-support/proposal.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
21
openspec/changes/archive/2026-03-03-pwa-support/tasks.md
Normal file
21
openspec/changes/archive/2026-03-03-pwa-support/tasks.md
Normal 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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-05
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 `"?"`
|
||||
|
|
@ -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)
|
||||
2
openspec/changes/mobile-layout/.openspec.yaml
Normal file
2
openspec/changes/mobile-layout/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-03
|
||||
113
openspec/changes/mobile-layout/design.md
Normal file
113
openspec/changes/mobile-layout/design.md
Normal 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.
|
||||
46
openspec/changes/mobile-layout/proposal.md
Normal file
46
openspec/changes/mobile-layout/proposal.md
Normal 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
|
||||
7
openspec/changes/mobile-layout/specs/editor-ui/spec.md
Normal file
7
openspec/changes/mobile-layout/specs/editor-ui/spec.md
Normal 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.
|
||||
84
openspec/changes/mobile-layout/specs/mobile-layout/spec.md
Normal file
84
openspec/changes/mobile-layout/specs/mobile-layout/spec.md
Normal 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).
|
||||
67
openspec/changes/mobile-layout/tasks.md
Normal file
67
openspec/changes/mobile-layout/tasks.md
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
56
openspec/specs/pwa/spec.md
Normal file
56
openspec/specs/pwa/spec.md
Normal 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
|
||||
12
openspec/specs/shared-color-utils/spec.md
Normal file
12
openspec/specs/shared-color-utils/spec.md
Normal 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
|
||||
16
openspec/specs/shared-text-utils/spec.md
Normal file
16
openspec/specs/shared-text-utils/spec.md
Normal 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 `"?"`
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
BIN
packages/demos/.webreel/raw/toolbar.mp4
Normal file
BIN
packages/demos/.webreel/raw/toolbar.mp4
Normal file
Binary file not shown.
1
packages/demos/.webreel/timelines/toolbar.timeline.json
Normal file
1
packages/demos/.webreel/timelines/toolbar.timeline.json
Normal file
File diff suppressed because one or more lines are too long
BIN
packages/demos/videos/toolbar.png
Normal file
BIN
packages/demos/videos/toolbar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
BIN
packages/demos/videos/toolbar.webm
Normal file
BIN
packages/demos/videos/toolbar.webm
Normal file
Binary file not shown.
BIN
public/pwa-192.png
Normal file
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
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
BIN
public/pwa-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
333
src/components/LayerTree.vue
Normal file
333
src/components/LayerTree.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
183
src/components/MobileDrawer.vue
Normal file
183
src/components/MobileDrawer.vue
Normal 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>
|
||||
231
src/components/MobileHud.vue
Normal file
231
src/components/MobileHud.vue
Normal 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>
|
||||
|
||||
134
src/components/MobileRibbon.vue
Normal file
134
src/components/MobileRibbon.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
1
src/env.d.ts
vendored
|
|
@ -1,4 +1,5 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/vanillajs" />
|
||||
/// <reference types="unplugin-icons/types/vue" />
|
||||
|
||||
declare module '*.vue' {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
10
src/utils/text.ts
Normal 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
28
src/utils/tools.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue