openpencil/openspec/changes/mobile-layout/design.md
Anton Soldatov 168c25c189
feat: mobile layout, PWA support (#27) (#27)
- Mobile-responsive editor with bottom drawer, ribbon nav, HUD overlay
- Touch support: single-finger tools, two-finger pinch-zoom
- PWA: manifest, service worker, installability
- Extract LayerTree from LayersPanel, shared utils (colorToCSS, initials, toolIcons)
- Constants moved to src/constants.ts, clipboard state to editor store
- reka-ui Popover/DropdownMenu in MobileHud

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-05 19:51:51 +03:00

5.3 KiB

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.