Merge origin/master into feat/configurable-snapping-guides

This commit is contained in:
Danila Poyarkov 2026-08-18 22:05:51 +03:00
commit 42b1fa07f0
64 changed files with 3565 additions and 2533 deletions

View file

@ -5,12 +5,8 @@
### Added
- Snap vector points, moved layers, and resized edges to nearby geometry, sibling layer bounds, canvas and frame layout guides, and whole-pixel coordinates with visible alignment guides, fractional-coordinate preservation when pixel snapping is off, and persistent geometry, object, and pixel-grid controls in General settings and the Preferences menu.
- Open multiple selected design files in separate tabs.
- Import, render, edit, resize, select, and export Figma text-on-path layers while preserving their curved glyph layout.
- Add tested pane-registry and recursive split-tree models for independently viewed same-document canvases, capped at four visible panes.
- Add explicit shared/view editor-state ownership and canvas render-state hooks as a foundation for independent same-document canvas panes.
- Improve collaboration efficiency by avoiding redundant unchanged-field Yjs writes and covering repeated and concurrent two-peer edits.
- Show Figma-style temporary distance measurements between selected and Option/Alt-hovered layers. (#491)
- Add a single CodeMirror editor for live Design JSX and HTML/CSS canvas previews, with Tailwind JSX viewing, completion, diagnostics, line numbers, bounded execution, and session-level undo. (#130)
- Allow supported AI model profiles to set a provider-specific reasoning effort. (#454)
@ -22,7 +18,7 @@
- Detect newer component-library revisions and explicitly apply stable-key updates to linked instances while preserving assignments and retaining old definitions. (#239)
- Let AI, MCP, and headless tools discover enabled library components, rank preferred libraries before local assets, and insert components by stable library identity. (#239)
- Publish the current document as an immutable local component library from the Assets panel, assigning durable collision-safe asset keys on first publication. (#239)
- Review component-library asset changes before acceptance and undo or redo accepted instance updates as one editor history action. (#239)
- Preview component-library asset changes before acceptance and undo or redo accepted instance updates as one editor history action. (#239)
- Preserve enabled-library bindings and materialized definition identities through `.fig` save/reopen, and reject Figma API or AI edits to read-only library definitions. (#239)
- Enforce read-only library capabilities across visual/layout Figma API setters, structural edits, editor history actions, variant authoring, and clipboard replacement while keeping instances editable. (#239)
- Publish component-library revisions to storage-provider object namespaces with immutable revision objects, validated manifests, and conflict-checked latest pointers. (#239)
@ -32,25 +28,25 @@
- Persist the selected local/storage catalog and preferred-library priorities, restoring them for Assets browsing and AI component ranking. (#239)
- Validate downloaded library revisions against size, node, image, content-hash, and revision-hash limits before caching or materialization. (#239)
- Manage bounded filesystem component-library catalogs from the CLI with JSON-capable `libraries list` and immutable `libraries publish` commands. (#239)
- Cover the complete local library lifecycle from multidimensional publication and cross-document insertion through `.fig` save/reopen and offline linked-instance editing. (#239)
- Track library update state per linked instance so individual instances or every instance of one asset can accept a revision while older definitions remain usable. (#239)
- Group outdated linked instances by library asset, filter updates by current page or all pages, and apply visible updates in one undoable transaction. (#239)
- Keep library update discovery read-only until acceptance and cover the populated managers page scope, atomic update, and graph immutability in Playwright. (#239)
- Show outdated linked library instances by asset and update the current page or all pages in one undoable action. (#239)
- Add a reproducible Dev Container for web, package, CLI, and non-browser test development.
- Add local crash recovery for unsaved and pathless documents, including MCP-created documents. (#487)
- Add isolated visual inspection that sends bounded selection renders to the configured Vision model and returns text findings without retaining image data in Design chat history. (#232, #471)
- Add image attachments to AI chat with bounded analysis, immediate transcript thumbnails, hover previews, and click-to-view images. (#232)
- Allow supported AI model profiles to set a provider-specific reasoning effort. (#454)
- Show unavailable or substituted document fonts with affected-layer selection and retry actions, and expose font fidelity through the Figma API and MCP tooling. (#503)
### Changed
- Pan horizontally with Shift+wheel while preserving native horizontal trackpad movement.
- Upgrade direct model chat providers and transports to AI SDK 7 while retaining the local ACP execution path.
- Localize file, clipboard, collaboration, chat, vectorization, storage, recovery, and component-library notifications in every supported language.
- Move MCP connections into their own Settings destination instead of presenting them as part of model configuration.
- Pan horizontally with Shift+wheel while preserving native horizontal trackpad movement.
### Performance
- Use cached axis-aligned world positions for hit testing untransformed layer chains and add representative 500/2,000-node interaction profiles. (#527)
- Avoid redundant collaboration writes when synchronized node fields have not changed.
- Release obsolete streamed Markdown parser history after each AI response completes, preventing chat memory from multiplying with every streamed chunk. (#544)
- Open large documents faster by using cached world positions while finding layers under the pointer. (#527)
- Coalesce writable-document autosaves that overlap an active `.fig` export while preserving a trailing save for newer edits. (#528)
- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500)
- Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500)
@ -58,16 +54,18 @@
### Fixed
- Keep text-editing carets, hit testing, and selection highlights aligned with vertically centered or bottom-aligned text. (#539)
- Match AI chat code-block syntax colors and backgrounds to the active light or dark theme. (#537)
- Let desktop users select and copy AI chat text without replacing it with the selected canvas layers. (#538)
- Restore visible above, below, and child drop feedback while dragging layers in the Layers panel.
- Place editor-created instances beside nested source components in world space, including transformed source and destination parents.
- Harden collaboration node synchronization against malformed remote source metadata and geometry while excluding derived text-renderer caches.
- Prevent malformed collaboration updates from corrupting synchronized nodes or derived text rendering.
- Transfer native `.fig` exports over binary Tauri IPC instead of JSON byte arrays, preventing large desktop saves from being truncated or exhausting WebView memory. (#484)
- Keep unsaved source-less documents recoverable after their editor tab is closed, matching Figma's retained offline-change behavior.
- Decode zstd-compressed FIG containers, reject invalid compressed payloads, and preserve exact fixture byte ranges. (#397)
- Compose caller CSS with Tailwind defaults when importing DOM/CSS documents. (#397)
- Preserve desktop HTTP timeout, abort, and empty-response semantics. (#397)
- Escape Vue SDK test selectors when the browser `CSS` global is unavailable. (#397)
- Resolve Vue SDK semantic test selectors correctly in non-browser runtimes. (#397)
- Report whether missing Figma clipboard images were actually fetched. (#397)
- Report exhausted provider credit, request failures, and output-token limits through localized chat toasts and copied diagnostics. (#451, #454)
- Prevent Windows desktop crashes when loading large system fonts for non-Latin text.
@ -80,6 +78,8 @@
- Preserve effective nested instance text overrides when importing complex Figma component hierarchies. (#102)
- Preserve SVG clip paths, including clip shapes referenced through `<use>`, when importing editable vectors.
- Preserve circles, ellipses, rectangles, lines, polylines, and polygons supplied as JSX children of inline SVG elements. (#452)
- Preserve component links when pasting Figma instances so later component edits continue to update them.
- Stop local MCP servers after the app disconnects instead of leaving orphaned background processes. (#494)
## 0.14.0 — 2026-08-10

2216
bun.lock

File diff suppressed because it is too large Load diff

2173
desktop/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -63,24 +63,26 @@
"generate:tauri-menu": "bun tools/tauri-menu/src/generate.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.74",
"@ai-sdk/deepseek": "^2.0.34",
"@ai-sdk/google": "^3.0.67",
"@ai-sdk/openai": "^3.0.58",
"@ai-sdk/valibot": "^2.0.27",
"@ai-sdk/vue": "^3.0.174",
"@atlaskit/pragmatic-drag-and-drop": "^1.7.9",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@ai-sdk/anthropic": "^4.0.39",
"@ai-sdk/deepseek": "^3.0.28",
"@ai-sdk/google": "^4.0.44",
"@ai-sdk/openai": "^4.0.42",
"@ai-sdk/valibot": "^3.0.27",
"@ai-sdk/vue": "^4.0.66",
"@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0",
"@chenglou/pretext": "^0.0.7",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/commands": "^6.11.0",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4",
"@codemirror/lint": "^6.9.7",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.43.8",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.9",
"@nanostores/i18n": "^1.3.3",
"@nanostores/vue": "^1.1.0",
"@open-pencil/cli": "workspace:*",
"@open-pencil/core": "workspace:*",
"@open-pencil/dom-css": "workspace:*",
@ -89,72 +91,73 @@
"@open-pencil/pen": "workspace:*",
"@open-pencil/scene-graph": "workspace:*",
"@open-pencil/vue": "workspace:*",
"@openrouter/ai-sdk-provider": "^2.9.0",
"@tailwindcss/vite": "^4.2.1",
"@openrouter/ai-sdk-provider": "^3.0.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/vue-table": "^8.21.3",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.1",
"@unhead/vue": "^2.1.10",
"@vueuse/core": "^14.2.1",
"@xmldom/xmldom": "^0.9.10",
"ai": "^6.0.174",
"@unhead/vue": "^2.1.17",
"@vueuse/core": "^14.4.0",
"@xmldom/xmldom": "^0.9.11",
"ai": "^7.0.66",
"aws4fetch": "^1.0.20",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"dedent": "^1.7.1",
"es-toolkit": "^1.46.1",
"fflate": "^0.8.2",
"dedent": "^1.7.2",
"es-toolkit": "^1.51.0",
"fflate": "^0.8.3",
"fzstd": "^0.1.1",
"hast-util-to-html": "^9.0.5",
"idb": "^8.0.3",
"jspdf": "^4.2.1",
"lib0": "^0.2.117",
"motion-v": "^2.0.0",
"motion-v": "^2.4.0",
"nanostores": "^1.5.0",
"opentype.js": "^2.0.0",
"reka-ui": "^2.9.0",
"reka-ui": "^2.10.3",
"svg2pdf.js": "^2.7.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1",
"tinykeys": "^3.0.0",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tinykeys": "^3.1.0",
"trystero": "^0.22.0",
"valibot": "^1.2.0",
"vue": "^3.5.29",
"vue-router": "^5.0.3",
"vue-stream-markdown": "^0.6.5",
"valibot": "^1.4.2",
"vue": "^3.5.41",
"vue-router": "^5.2.0",
"vue-stream-markdown": "1.0.4",
"y-indexeddb": "^9.0.12",
"y-protocols": "^1.0.7",
"yjs": "^13.6.29",
"yjs": "^13.6.32",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.3",
"zod": "^4.3.6"
"zod": "^4.4.3"
},
"devDependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
"@arethetypeswrong/cli": "0.18.4",
"@feature-sliced/steiger-plugin": "^0.5.8",
"@figma/plugin-typings": "^1.126.0",
"@hono/node-server": "^1.19.11",
"@iconify-json/lucide": "^1.2.94",
"@modelcontextprotocol/sdk": "^1.27.1",
"@figma/plugin-typings": "^1.134.0",
"@hono/node-server": "^1.19.17",
"@iconify-json/lucide": "^1.2.123",
"@modelcontextprotocol/sdk": "^1.30.0",
"@open-pencil/mcp": "workspace:*",
"@playwright/test": "^1.58.2",
"@storybook/addon-a11y": "^10.4.0",
"@storybook/addon-docs": "^10.4.0",
"@storybook/addon-themes": "^10.4.0",
"@storybook/vue3-vite": "^10.4.0",
"@tauri-apps/cli": "^2",
"@playwright/test": "^1.62.1",
"@storybook/addon-a11y": "^10.5.8",
"@storybook/addon-docs": "^10.5.8",
"@storybook/addon-themes": "^10.5.8",
"@storybook/vue3-vite": "^10.5.8",
"@tauri-apps/cli": "^2.11.4",
"@types/culori": "^4.0.1",
"@types/node": "^25.3.2",
"@types/opentype.js": "^1.3.9",
"@types/node": "^25.9.5",
"@types/opentype.js": "^1.3.10",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "^7.0.0-dev.20260228.1",
"@vitejs/plugin-vue": "^6.0.5",
"@typescript/native-preview": "^7.0.0-dev.20260707.2",
"@vitejs/plugin-vue": "^6.0.8",
"@wdio/cli": "9.30.1",
"@wdio/globals": "9.29.1",
"@wdio/local-runner": "9.30.1",
@ -163,40 +166,41 @@
"@wdio/tauri-service": "1.3.0",
"changelog-parser": "^4.1.0",
"cloudflare-redirect-parser": "^1.0.0",
"esbuild": "^0.27.3",
"esbuild": "^0.27.7",
"expect-webdriverio": "5.7.0",
"fake-indexeddb": "^6.2.5",
"franc": "^6.2.0",
"hono": "^4.12.5",
"jscpd": "^4.0.8",
"hono": "^4.13.2",
"jscpd": "^4.3.0",
"knip": "6.20.0",
"mdast": "^3.0.0",
"mdast-util-from-markdown": "^2.0.3",
"mitata": "^1.0.34",
"oxfmt": "^0.35.0",
"oxlint": "^1.50.0",
"oxlint": "1.57.0",
"oxlint-tsgolint": "^0.16.0",
"publint": "0.3.20",
"sherif": "1.12.0",
"steiger": "^0.5.12",
"storybook": "^10.4.0",
"tsdown": "^0.22.3",
"steiger": "^0.5.13",
"storybook": "^10.5.8",
"tsdown": "^0.22.14",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"unist-util-visit": "^5.1.0",
"unplugin-icons": "^23.0.1",
"unplugin-raw": "^0.6.4",
"unplugin-vue": "^7.1.1",
"unplugin-vue-components": "^31.0.0",
"unplugin-raw": "^0.7.0",
"unplugin-vue": "^7.2.0",
"unplugin-vue-components": "^31.1.0",
"vite": "8.1.4",
"vite-plugin-pwa": "^1.2.0",
"vue-tsc": "^3.2.5",
"workbox-window": "^7.4.0",
"ws": "^8.19.0"
"vite-plugin-pwa": "^1.3.0",
"vue-tsc": "^3.3.10",
"workbox-window": "^7.4.1",
"ws": "^8.21.3"
},
"overrides": {
"protobufjs": "7.5.5",
"websocket-driver": "0.7.5"
"websocket-driver": "0.7.5",
"@codemirror/view": "6.43.9"
},
"packageManager": "bun@1.3.10"
}

View file

@ -173,24 +173,24 @@
},
"dependencies": {
"@chenglou/pretext": "^0.0.7",
"@iconify/utils": "^3.1.0",
"@iconify/utils": "^3.1.4",
"@open-pencil/fig": "workspace:*",
"@open-pencil/kiwi": "workspace:*",
"@open-pencil/pen": "workspace:*",
"@open-pencil/scene-graph": "workspace:*",
"@tauri-apps/api": "^2",
"@xmldom/xmldom": "^0.9.10",
"acorn": "^8.16.0",
"@tauri-apps/api": "^2.11.1",
"@xmldom/xmldom": "^0.9.11",
"acorn": "^8.18.0",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"destr": "^2.0.5",
"diff": "^8.0.3",
"es-toolkit": "^1.46.1",
"diff": "^8.0.4",
"es-toolkit": "^1.51.0",
"expr-eval": "^2.0.2",
"fflate": "^0.8.2",
"fflate": "^0.8.3",
"fontoxpath": "^3.34.0",
"fzstd": "^0.1.1",
"js-base64": "^3.9.2",
"js-base64": "^3.9.3",
"jspdf": "^4.2.1",
"nanoevents": "^9.1.0",
"ofetch": "^1.5.1",
@ -201,13 +201,13 @@
"svg2pdf.js": "^2.7.0",
"svgpath": "^2.6.0",
"twirlwind": "^0.3.0",
"unifont": "^0.7.4",
"unifont": "0.7.4",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.3"
},
"devDependencies": {
"@types/culori": "^4.0.1",
"@types/diff": "^8.0.0",
"@types/opentype.js": "^1.3.9",
"@types/opentype.js": "^1.3.10",
"typescript": "~5.8.3"
}
}

View file

@ -72,7 +72,7 @@ export {
} from './schema'
export { transformDesignJSXExpression } from './transform'
import jsxReference from '#core/tools/prompts/jsx-reference.md'
import jsxReference from '#core/tools/prompts/jsx-reference.md?raw'
export { sceneNodeToJSX, selectionToJSX, type JSXFormat } from '#core/io/formats/jsx'
export const JSX_REFERENCE: string = jsxReference

View file

@ -1,5 +1,5 @@
import codegenPrompt from './tools/prompts/codegen.md'
import jsxReference from './tools/prompts/jsx-reference.md'
import codegenPrompt from './tools/prompts/codegen.md?raw'
import jsxReference from './tools/prompts/jsx-reference.md?raw'
export { randomHex, randomInt, randomIndex } from './random'

View file

@ -34,6 +34,20 @@ export class TextEditor {
this.ck = ck
}
private paragraphVerticalOffset(): number {
const s = this._state
const node = this.paragraphNode
if (!s?.paragraph || !node) return 0
const available = Math.max(0, node.height - s.paragraph.getHeight())
if (node.textAlignVertical === 'CENTER') return available / 2
if (node.textAlignVertical === 'BOTTOM') return available
return 0
}
private paragraphY(y: number): number {
return y - this.paragraphVerticalOffset()
}
private prepareMove(extend: boolean): TextEditorState | null {
const s = this._state
if (!s) return null
@ -170,7 +184,7 @@ export class TextEditor {
setCursorAt(x: number, y: number, extend = false): void {
const s = this._state
if (!s?.paragraph) return
const pos = s.paragraph.getGlyphPositionAtCoordinate(x, y).pos
const pos = s.paragraph.getGlyphPositionAtCoordinate(x, this.paragraphY(y)).pos
if (extend) {
if (s.selectionAnchor === null) s.selectionAnchor = s.cursor
} else {
@ -193,14 +207,14 @@ export class TextEditor {
selectWordAt(x: number, y: number): void {
const s = this._state
if (!s?.paragraph) return
const pos = s.paragraph.getGlyphPositionAtCoordinate(x, y).pos
const pos = s.paragraph.getGlyphPositionAtCoordinate(x, this.paragraphY(y)).pos
this.selectWord(pos)
}
selectLineAt(x: number, y: number): void {
const s = this._state
if (!s?.paragraph) return
const pos = s.paragraph.getGlyphPositionAtCoordinate(x, y).pos
const pos = s.paragraph.getGlyphPositionAtCoordinate(x, this.paragraphY(y)).pos
this.selectLine(pos)
}
@ -256,7 +270,7 @@ export class TextEditor {
if (!caret) return
const fontSize = s.paragraph.getLineMetrics()[0]?.height ?? 14
const y = edge === 'up' ? caret.y0 - fontSize / 2 : caret.y1 + fontSize / 2
s.cursor = s.paragraph.getGlyphPositionAtCoordinate(caret.x, y).pos
s.cursor = s.paragraph.getGlyphPositionAtCoordinate(caret.x, this.paragraphY(y)).pos
}
moveUp(extend = false): void {
@ -338,7 +352,8 @@ export class TextEditor {
const metrics = s.paragraph.getLineMetrics()
if (metrics.length === 0) return null
const line = metrics[0]
return { x: line.left, y0: 0, y1: line.height }
const offsetY = this.paragraphVerticalOffset()
return { x: line.left, y0: offsetY, y1: offsetY + line.height }
}
let lo: number
@ -366,10 +381,11 @@ export class TextEditor {
)
if (rects.length === 0) return null
const [left, top, right, bottom] = rects[0].rect
const offsetY = this.paragraphVerticalOffset()
return {
x: useRight ? right : left,
y0: top,
y1: bottom
y0: top + offsetY,
y1: bottom + offsetY
}
}
@ -385,9 +401,10 @@ export class TextEditor {
this.ck.RectHeightStyle.Max,
this.ck.RectWidthStyle.Tight
)
const offsetY = this.paragraphVerticalOffset()
return rects.map((r) => {
const [left, top, right, bottom] = r.rect
return { x: left, y: top, width: right - left, height: bottom - top }
return { x: left, y: top + offsetY, width: right - left, height: bottom - top }
})
}
}

View file

@ -1,4 +1,4 @@
import codegenPrompt from './prompts/codegen.md'
import codegenPrompt from './prompts/codegen.md?raw'
export { getComponentCatalog, registerComponentCatalog } from './component-catalog'
export type {

View file

@ -6,9 +6,9 @@ import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vitepress'
import llmstxt from 'vitepress-plugin-llms'
import { docsLocales } from './locales'
import { rootThemeConfig } from './root-theme'
import { BASE, LOCALE_PREFIXES, applyPageSeo, siteHead, withAlternateSitemapLinks } from './seo'
import { docsLocales } from './locales.ts'
import { rootThemeConfig } from './root-theme.ts'
import { BASE, LOCALE_PREFIXES, applyPageSeo, siteHead, withAlternateSitemapLinks } from './seo.ts'
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
const fastBuild = process.env.OPENPENCIL_DOCS_FAST_BUILD === '1'

View file

@ -1,13 +1,13 @@
import { sdkSidebar } from './sdk-sidebar'
import { sdkSidebar } from './sdk-sidebar.ts'
import {
developmentSidebar,
guideSidebar,
programmableSidebar,
referenceSidebar,
userGuideSidebar,
} from './sidebars'
} from './sidebars.ts'
import type { NavLabels, ProgrammableLabels, SidebarLabels } from './labels'
import type { NavLabels, ProgrammableLabels, SidebarLabels } from './labels.ts'
import type { DefaultTheme } from 'vitepress'
export const localeThemeConfig = (

View file

@ -11,8 +11,8 @@ import {
PL_PROG,
RU,
RU_PROG,
} from './labels'
import { localeThemeConfig } from './locale-theme'
} from './labels.ts'
import { localeThemeConfig } from './locale-theme.ts'
export const docsLocales = {
root: {

View file

@ -1,12 +1,12 @@
import { EN, EN_PROG } from './labels'
import { sdkSidebar } from './sdk-sidebar'
import { EN, EN_PROG } from './labels.ts'
import { sdkSidebar } from './sdk-sidebar.ts'
import {
developmentSidebar,
guideSidebar,
programmableSidebar,
referenceSidebar,
userGuideSidebar,
} from './sidebars'
} from './sidebars.ts'
import type { DefaultTheme } from 'vitepress'

View file

@ -1,4 +1,4 @@
import type { ProgrammableLabels, SidebarLabels } from './labels'
import type { ProgrammableLabels, SidebarLabels } from './labels.ts'
import type { DefaultTheme } from 'vitepress'
export const guideSidebar = (prefix: string, labels: SidebarLabels): DefaultTheme.SidebarItem[] => [

View file

@ -15,14 +15,14 @@
"preview": "vitepress preview"
},
"devDependencies": {
"@shikijs/vitepress-twoslash": "^4.3.1",
"@tailwindcss/vite": "^4.2.1",
"vitepress": "^2.0.0-alpha.17",
"@shikijs/vitepress-twoslash": "^4.4.3",
"@tailwindcss/vite": "^4.3.3",
"vitepress": "^2.0.0-alpha.19",
"vitepress-plugin-llms": "1.12.2",
"vue": "^3.5.29",
"vue-component-meta": "^3.3.7"
"vue": "^3.5.41",
"vue-component-meta": "^3.3.10"
},
"dependencies": {
"@iconify/vue": "^5.0.0"
"@iconify/vue": "^5.0.1"
}
}

View file

@ -66,14 +66,14 @@
},
"devDependencies": {
"@types/bun": "^1.3.14",
"tsdown": "^0.22.3",
"tsdown": "^0.22.14",
"typescript": "~5.8.3"
},
"dependencies": {
"@acemir/cssom": "^0.9.31",
"parse5": "^8.0.1",
"postcss-value-parser": "^4.2.0",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.3.3",
"twirlwind": "^0.3.0"
}
}

View file

@ -52,12 +52,12 @@
"@open-pencil/scene-graph": "workspace:*"
},
"dependencies": {
"es-toolkit": "^1.46.1",
"fflate": "^0.8.2"
"es-toolkit": "^1.51.0",
"fflate": "^0.8.3"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"tsdown": "^0.22.3",
"tsdown": "^0.22.14",
"typescript": "~5.8.3"
}
}

View file

@ -72,12 +72,12 @@
"provenance": true
},
"dependencies": {
"fflate": "^0.8.2",
"fflate": "^0.8.3",
"fzstd": "^0.1.1"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"tsdown": "^0.22.3",
"tsdown": "^0.22.14",
"typescript": "~5.8.3"
}
}

View file

@ -56,16 +56,16 @@
"provenance": true
},
"dependencies": {
"@hono/node-server": "^1.19.11",
"@modelcontextprotocol/sdk": "^1.27.1",
"@hono/node-server": "^1.19.17",
"@modelcontextprotocol/sdk": "^1.30.0",
"@open-pencil/core": "workspace:*",
"hono": "^4.12.5",
"package-manager-detector": "^1.6.0",
"ws": "^8.19.0",
"zod": "^4.3.6"
"hono": "^4.13.2",
"package-manager-detector": "^1.8.0",
"ws": "^8.21.3",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^25.3.2",
"@types/node": "^25.9.5",
"@types/ws": "^8.18.1"
}
}

View file

@ -43,7 +43,7 @@
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/culori": "^4.0.1",
"tsdown": "^0.22.3",
"tsdown": "^0.22.14",
"typescript": "~5.8.3"
}
}

View file

@ -173,13 +173,13 @@
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"es-toolkit": "^1.46.1",
"es-toolkit": "^1.51.0",
"nanoevents": "^9.1.0",
"svgpath": "^2.6.0"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"tsdown": "^0.22.3",
"tsdown": "^0.22.14",
"typescript": "~5.8.3"
}
}

View file

@ -32,8 +32,8 @@
},
"peerDependencies": {
"@open-pencil/core": "workspace:*",
"canvaskit-wasm": ">=0.39.0",
"vue": "^3.4.0"
"canvaskit-wasm": "^0.40.0",
"vue": "^3.5.41"
},
"peerDependenciesMeta": {
"canvaskit-wasm": {
@ -41,19 +41,19 @@
}
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.9",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@nanostores/i18n": "^1.2.2",
"@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0",
"@nanostores/i18n": "^1.3.3",
"@nanostores/vue": "^1.1.0",
"@open-pencil/scene-graph": "workspace:*",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.2.1",
"nanostores": "^1.2.0",
"reka-ui": "^2.9.0"
"@vueuse/core": "^14.4.0",
"nanostores": "^1.5.0",
"reka-ui": "^2.10.3"
},
"devDependencies": {
"tsdown": "^0.22.3",
"unplugin-raw": "^0.6.4",
"unplugin-vue": "^7.1.1"
"tsdown": "^0.22.14",
"unplugin-raw": "^0.7.0",
"unplugin-vue": "^7.2.0"
}
}

View file

@ -1,11 +1,20 @@
import type { ChatTransport, UIMessage } from 'ai'
import type { CollabReturn } from '@/app/collab/context'
import type { EditorStore } from '@/app/editor/session/create'
import { IS_BROWSER } from '@/constants'
export interface OpenPencilTestHooks {
writeCount?: () => number
mockHandle?: FileSystemFileHandle
savedOpen?: Window['open']
collab?: Pick<
CollabReturn,
'connect' | 'disconnect' | 'updateCursor' | 'updateSelection' | 'setLocalName'
> & {
peerCount: () => number
peerSelections: () => Array<string[] | undefined>
}
}
export interface OpenPencilWindowAPI {
@ -37,6 +46,21 @@ export function setOpenPencilStore(store: EditorStore) {
windowAPI()
}
export function exposeCollaborationActions(collab: CollabReturn) {
if (!IS_BROWSER || !import.meta.env.DEV) return
if (!new URLSearchParams(window.location.search).has('test')) return
const testHooks = (windowAPI().test ??= {})
testHooks.collab = {
connect: collab.connect,
disconnect: collab.disconnect,
updateCursor: collab.updateCursor,
updateSelection: collab.updateSelection,
setLocalName: collab.setLocalName,
peerCount: () => collab.remotePeers.value.length,
peerSelections: () => collab.remotePeers.value.map((peer) => peer.selection)
}
}
export function exposeChatTransportOverride(
setChatTransport: (factory: () => ChatTransport<UIMessage>) => void
) {

View file

@ -1,83 +1,73 @@
import type { Room } from 'trystero'
import { joinRoom as joinTrysteroRoom } from 'trystero/mqtt'
import * as decoding from 'lib0/decoding'
import * as awarenessProtocol from 'y-protocols/awareness'
import * as Y from 'yjs'
import { TRYSTERO_APP_ID } from '@/constants'
import { joinCollabRoom, type JoinCollabRoom } from '@/app/collab/transport'
type CollabRoomOptions = {
export type CollabRoomOptions = {
roomId: string
ydoc: Y.Doc
awareness: awarenessProtocol.Awareness
setConnected: () => void
updatePeersList: () => void
joinRoom?: JoinCollabRoom
}
export type CollabRoomConnection = {
room: Room
room: ReturnType<JoinCollabRoom>
sendYjsUpdate: (data: Uint8Array, peerId?: string) => void
sendAwareness: (data: Uint8Array, peerId?: string) => void
sendSyncStep1: (data: Uint8Array, peerId?: string) => void
}
function awarenessClientIds(data: Uint8Array): number[] {
try {
const decoder = decoding.createDecoder(data)
const count = decoding.readVarUint(decoder)
const clients: number[] = []
for (let index = 0; index < count; index++) {
clients.push(decoding.readVarUint(decoder))
decoding.readVarUint(decoder)
decoding.readVarString(decoder)
}
return clients
} catch {
return []
}
}
export function connectCollabRoom({
roomId,
ydoc,
awareness,
setConnected,
updatePeersList
updatePeersList,
joinRoom = joinCollabRoom
}: CollabRoomOptions): CollabRoomConnection {
const room = joinTrysteroRoom(
{
appId: TRYSTERO_APP_ID,
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' },
{
urls: 'turn:openrelay.metered.ca:443',
username: 'openrelayproject',
credential: 'openrelayproject'
},
{
urls: 'turn:openrelay.metered.ca:443?transport=tcp',
username: 'openrelayproject',
credential: 'openrelayproject'
}
]
}
},
roomId
)
const room = joinRoom(roomId)
const [sendYjsUpdate, getUpdate] = room.makeAction('yjs-update')
const [sendAwareness, getAwareness] = room.makeAction('awareness')
const [sendSyncStep1, getSyncStep1] = room.makeAction('sync-step1')
const [sendSyncReply, getSyncReply] = room.makeAction('sync-reply')
const [sendUpdate, getUpdate] = room.makeAction<Uint8Array>('yjs-update')
const [sendAw, getAw] = room.makeAction<Uint8Array>('awareness')
const [sendSync, getSync] = room.makeAction<Uint8Array>('sync-step1')
const [sendSyncReply, getSyncReply] = room.makeAction<Uint8Array>('sync-reply')
const sendYjsUpdate = (data: Uint8Array, peerId?: string) =>
void (peerId ? sendUpdate(data, peerId) : sendUpdate(data))
const sendAwareness = (data: Uint8Array, peerId?: string) =>
void (peerId ? sendAw(data, peerId) : sendAw(data))
const sendSyncStep1 = (data: Uint8Array, peerId?: string) =>
void (peerId ? sendSync(data, peerId) : sendSync(data))
const awarenessClientsByPeer = new Map<string, Set<number>>()
getUpdate((data) => {
Y.applyUpdate(ydoc, new Uint8Array(data), 'remote')
Y.applyUpdate(ydoc, data, 'remote')
})
getAw((data) => {
awarenessProtocol.applyAwarenessUpdate(awareness, new Uint8Array(data), null)
getAwareness((data, peerId) => {
awarenessClientsByPeer.set(peerId, new Set(awarenessClientIds(data)))
awarenessProtocol.applyAwarenessUpdate(awareness, data, 'remote')
})
getSync((data, peerId) => {
const sv = new Uint8Array(data)
const update = Y.encodeStateAsUpdate(ydoc, sv)
void sendSyncReply(update, peerId)
getSyncStep1((stateVector, peerId) => {
const update = Y.encodeStateAsUpdate(ydoc, stateVector)
sendSyncReply(update, peerId)
})
getSyncReply((data) => {
Y.applyUpdate(ydoc, new Uint8Array(data), 'remote')
Y.applyUpdate(ydoc, data, 'remote')
})
ydoc.on('update', (update: Uint8Array, origin: unknown) => {
@ -87,7 +77,11 @@ export function connectCollabRoom({
awareness.on(
'update',
({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => {
(
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
origin: unknown
) => {
if (origin === 'remote' || origin === 'peer-left') return
const changedClients = [...added, ...updated, ...removed]
const encodedUpdate = awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients)
sendAwareness(encodedUpdate)
@ -96,17 +90,13 @@ export function connectCollabRoom({
room.onPeerJoin((peerId) => {
setConnected()
const sv = Y.encodeStateVector(ydoc)
sendSyncStep1(sv, peerId)
const encodedUpdate = awarenessProtocol.encodeAwarenessUpdate(awareness, [awareness.clientID])
sendAwareness(encodedUpdate, peerId)
sendSyncStep1(Y.encodeStateVector(ydoc), peerId)
sendAwareness(awarenessProtocol.encodeAwarenessUpdate(awareness, [awareness.clientID]), peerId)
})
room.onPeerLeave(() => {
const remoteClients = [...awareness.getStates().keys()].filter(
(id) => id !== awareness.clientID
)
room.onPeerLeave((peerId) => {
const remoteClients = [...(awarenessClientsByPeer.get(peerId) ?? [])]
awarenessClientsByPeer.delete(peerId)
awarenessProtocol.removeAwarenessStates(awareness, remoteClients, 'peer-left')
updatePeersList()
})

View file

@ -1,4 +1,3 @@
import type { Room } from 'trystero'
import type { Ref } from 'vue'
import { IndexeddbPersistence } from 'y-indexeddb'
import * as awarenessProtocol from 'y-protocols/awareness'
@ -8,6 +7,7 @@ import * as Y from 'yjs'
import { randomIndex } from '@open-pencil/core/random'
import { connectCollabRoom } from '@/app/collab/room'
import type { CollabRoomTransport } from '@/app/collab/transport'
import type { CollabState } from '@/app/collab/types'
import { bindCollabGraphEvents, registerYjsObservers } from '@/app/collab/yjs-sync'
import type { EditorStore } from '@/app/editor/active-store'
@ -18,7 +18,7 @@ export type CollabRuntime = {
awareness: awarenessProtocol.Awareness | null
ynodes: Y.Map<Y.Map<unknown>> | null
yimages: Y.Map<Uint8Array> | null
room: Room | null
room: CollabRoomTransport | null
persistence: IndexeddbPersistence | null
connectedStore: EditorStore | null
suppressGraphSync: boolean
@ -54,7 +54,7 @@ type CollabConnectionActionsOptions = {
type CollabSessionResources = {
store: EditorStore
room: Room | null
room: CollabRoomTransport | null
awareness: awarenessProtocol.Awareness | null
persistence: IndexeddbPersistence | null
ydoc: Y.Doc | null

View file

@ -0,0 +1,15 @@
import { IS_BROWSER } from '@/constants'
import { joinTestCollabRoom } from './test'
import { joinTrysteroCollabRoom } from './trystero'
import type { JoinCollabRoom } from './types'
function usesTestTransport(): boolean {
if (!IS_BROWSER || !import.meta.env.DEV) return false
return new URLSearchParams(window.location.search).get('collabTransport') === 'test'
}
export const joinCollabRoom: JoinCollabRoom = (roomId) =>
usesTestTransport() ? joinTestCollabRoom(roomId) : joinTrysteroCollabRoom(roomId)
export type { CollabRoomTransport, JoinCollabRoom } from './types'

View file

@ -0,0 +1,159 @@
import { IS_BROWSER } from '@/constants'
import type { CollabAction, CollabActionReceiver, CollabRoomTransport } from './types'
const MAX_TEST_MESSAGE_BYTES = 8 * 1024 * 1024
type TestTransportMessage =
| { type: 'hello'; senderId: string; targetId?: string }
| { type: 'welcome'; senderId: string; targetId?: string }
| { type: 'leave'; senderId: string; targetId?: string }
| {
type: 'action'
senderId: string
targetId?: string
namespace: string
data: number[]
}
function parseMessage(value: unknown): TestTransportMessage | null {
if (typeof value !== 'string' || value.length > MAX_TEST_MESSAGE_BYTES) return null
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
return null
}
if (!parsed || typeof parsed !== 'object' || !('type' in parsed) || !('senderId' in parsed)) {
return null
}
const message = parsed as Partial<TestTransportMessage>
if (typeof message.senderId !== 'string') return null
if (message.targetId !== undefined && typeof message.targetId !== 'string') return null
if (message.type === 'hello' || message.type === 'welcome' || message.type === 'leave') {
return message as TestTransportMessage
}
if (
message.type !== 'action' ||
typeof message.namespace !== 'string' ||
!Array.isArray(message.data) ||
!message.data.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)
) {
return null
}
return message as TestTransportMessage
}
function relayURL(roomId: string): URL {
if (!IS_BROWSER) {
throw new Error('Test collaboration transport requires a browser')
}
const configured = new URLSearchParams(window.location.search).get('collabRelay')
if (!configured) throw new Error('Test collaboration transport requires collabRelay')
const url = new URL(configured)
url.searchParams.set('roomId', roomId)
return url
}
export function joinTestCollabRoom(roomId: string): CollabRoomTransport {
if (
!IS_BROWSER ||
typeof WebSocket === 'undefined' ||
typeof crypto === 'undefined' ||
typeof crypto.randomUUID !== 'function'
) {
throw new Error('Test collaboration transport requires browser WebSocket and crypto APIs')
}
const peerId = crypto.randomUUID()
const socket = new WebSocket(relayURL(roomId))
const peers = new Set<string>()
const receivers = new Map<string, CollabActionReceiver>()
const pending: TestTransportMessage[] = []
let joinHandler: ((peerId: string) => void) | null = null
let leaveHandler: ((peerId: string) => void) | null = null
let left = false
function post(message: TestTransportMessage) {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message))
else pending.push(message)
}
function addPeer(id: string) {
if (id === peerId || peers.has(id)) return
peers.add(id)
joinHandler?.(id)
}
socket.addEventListener('open', () => {
for (const message of pending.splice(0)) socket.send(JSON.stringify(message))
post({ type: 'hello', senderId: peerId })
})
socket.addEventListener('error', () => {
console.error('Test collaboration relay connection failed', socket.url)
})
socket.addEventListener('message', (event: MessageEvent<string>) => {
const message = parseMessage(event.data)
if (!message || message.senderId === peerId) return
if (message.targetId && message.targetId !== peerId) return
if (message.type === 'hello') {
addPeer(message.senderId)
post({ type: 'welcome', senderId: peerId, targetId: message.senderId })
return
}
if (message.type === 'welcome') {
addPeer(message.senderId)
return
}
if (message.type === 'leave') {
if (peers.delete(message.senderId)) leaveHandler?.(message.senderId)
} else {
addPeer(message.senderId)
receivers.get(message.namespace)?.(new Uint8Array(message.data), message.senderId)
}
})
return {
makeAction(namespace): CollabAction {
return [
(data, targetId) => {
post({
type: 'action',
senderId: peerId,
targetId,
namespace,
data: Array.from(data)
})
},
(handler) => {
if (receivers.has(namespace)) {
throw new Error(`Collaboration action ${namespace} is already registered`)
}
receivers.set(namespace, handler)
}
]
},
onPeerJoin(handler) {
joinHandler = handler
for (const id of peers) queueMicrotask(() => handler(id))
},
onPeerLeave(handler) {
leaveHandler = handler
},
async leave() {
if (left) return
left = true
peers.clear()
receivers.clear()
const leaveMessage = JSON.stringify({ type: 'leave', senderId: peerId })
if (socket.readyState === WebSocket.CONNECTING) {
await new Promise<void>((resolve) => {
socket.addEventListener('open', () => resolve(), { once: true })
socket.addEventListener('error', () => resolve(), { once: true })
socket.addEventListener('close', () => resolve(), { once: true })
})
}
if (socket.readyState === WebSocket.OPEN) socket.send(leaveMessage)
socket.close()
}
}
}

View file

@ -0,0 +1,45 @@
import { joinRoom as joinTrysteroRoom } from 'trystero/mqtt'
import { TRYSTERO_APP_ID } from '@/constants'
import type { CollabAction, JoinCollabRoom } from './types'
export const joinTrysteroCollabRoom: JoinCollabRoom = (roomId) => {
const room = joinTrysteroRoom(
{
appId: TRYSTERO_APP_ID,
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' },
{
urls: 'turn:openrelay.metered.ca:443',
username: 'openrelayproject',
credential: 'openrelayproject'
},
{
urls: 'turn:openrelay.metered.ca:443?transport=tcp',
username: 'openrelayproject',
credential: 'openrelayproject'
}
]
}
},
roomId
)
return {
makeAction(namespace): CollabAction {
const [send, receive] = room.makeAction<Uint8Array>(namespace)
return [
(data, peerId) => void (peerId ? send(data, peerId) : send(data)),
(handler) => receive((data, peerId) => handler(new Uint8Array(data), peerId))
]
},
onPeerJoin: (handler) => room.onPeerJoin(handler),
onPeerLeave: (handler) => room.onPeerLeave(handler),
leave: async () => {
await room.leave()
}
}
}

View file

@ -0,0 +1,14 @@
export type CollabActionReceiver = (data: Uint8Array, peerId: string) => void
export type CollabAction = [
send: (data: Uint8Array, peerId?: string) => void,
receive: (handler: CollabActionReceiver) => void
]
export interface CollabRoomTransport {
makeAction(namespace: string): CollabAction
onPeerJoin(handler: (peerId: string) => void): void
onPeerLeave(handler: (peerId: string) => void): void
leave(): Promise<void>
}
export type JoinCollabRoom = (roomId: string) => CollabRoomTransport

View file

@ -3,6 +3,7 @@ import { browserHTMLToSceneGraph } from '@open-pencil/dom-css/browser'
import { yieldToUI } from '@/app/document/io/browser'
import { applyImportedDocument } from '@/app/document/io/imported-document'
import { notificationMessages } from '@/app/i18n/notifications'
import { toast } from '@/app/shell/ui'
type OpenDOMDocumentState = EditorState & {
@ -33,6 +34,10 @@ type DOMTextImportOptions = {
documentName?: string
}
function errorDetail(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function documentNameFor(file: File): string {
return file.name.replace(/\.(html?|xhtml)$/i, '')
}
@ -60,10 +65,10 @@ export function createDOMOpenActions({
state.loading = true
const pageName = await applyDOMText(html, options)
setDocumentSource(`${pageName}.html`, 'html')
toast.info('Imported DOM/CSS document')
toast.info(notificationMessages.get().importedDOMCSS)
} catch (e) {
console.error('Failed to import DOM/CSS:', e)
toast.error(`Failed to import DOM/CSS: ${e instanceof Error ? e.message : String(e)}`)
toast.error(notificationMessages.get().importDOMCSSFailed({ error: errorDetail(e) }))
throw e
} finally {
state.loading = false
@ -81,7 +86,7 @@ export function createDOMOpenActions({
setDocumentSource(file.name, 'html', options.handle, options.path)
} catch (e) {
console.error('Failed to open DOM/CSS file:', e)
toast.error(`Failed to open DOM/CSS file: ${e instanceof Error ? e.message : String(e)}`)
toast.error(notificationMessages.get().openDOMCSSFailed({ error: errorDetail(e) }))
} finally {
state.loading = false
}

View file

@ -6,6 +6,7 @@ import { yieldToUI } from '@/app/document/io/browser'
import { applyImportedDocument } from '@/app/document/io/imported-document'
import { readReloadSource } from '@/app/document/io/reload-source'
import { captureReloadState, restoreReloadState } from '@/app/document/io/reload-state'
import { notificationMessages } from '@/app/i18n/notifications'
import { toast } from '@/app/shell/ui'
type OpenDocumentState = EditorState & {
@ -54,7 +55,12 @@ export function createOpenActions({
editor.requestRender()
} catch (e) {
console.error('Failed to open .fig file:', e)
toast.error(`Failed to open file: ${e instanceof Error ? e.message : String(e)}`)
toast.error(
notificationMessages.get().openFileFailed({
name: file.name,
error: e instanceof Error ? e.message : String(e)
})
)
} finally {
state.loading = false
}

View file

@ -7,6 +7,7 @@ import type { EditorStore } from '@/app/editor/active-store'
import { pasteClipboardToReplace } from '@/app/editor/clipboard/paste-to-replace'
import { executeClipboardCommand } from '@/app/editor/clipboard/system'
import { canVectorizeImageNode, vectorizeImageNode } from '@/app/editor/vectorize'
import { notificationMessages } from '@/app/i18n/notifications'
import { toast } from '@/app/shell/ui'
import { writeTauriClipboardText } from '@/app/tauri/clipboard'
import { isTauri } from '@/app/tauri/env'
@ -26,7 +27,7 @@ export function createCanvasMenuActions(store: EditorStore, selectedIds: Ref<Set
function execCommand(cmd: 'copy' | 'cut' | 'paste') {
void executeClipboardCommand(store, cmd).then((ok) => {
if (!ok) toast.error('Clipboard access is blocked in this browser context')
if (!ok) toast.error(notificationMessages.get().clipboardAccessBlocked)
return undefined
})
}
@ -38,13 +39,17 @@ export function createCanvasMenuActions(store: EditorStore, selectedIds: Ref<Set
} else {
await copy(text)
}
toast.info(`Copied as ${label}`)
toast.info(notificationMessages.get().copiedAs({ format: label }))
}
async function copyNodeId() {
const nodeIds = ids()
if (nodeIds.length === 0) return
await clipboardWrite(nodeIds.join(', '), `node ID${nodeIds.length > 1 ? 's' : ''}`)
const messages = notificationMessages.get()
await clipboardWrite(
nodeIds.join(', '),
nodeIds.length > 1 ? messages.nodeIDs : messages.nodeID
)
}
async function copyXPath() {
@ -54,19 +59,20 @@ export function createCanvasMenuActions(store: EditorStore, selectedIds: Ref<Set
.map((id) => nodeToXPath(store.graph, id))
.filter((xpath): xpath is string => xpath !== null)
if (xpaths.length === 0) return
await clipboardWrite(xpaths.join('\n'), `XPath${xpaths.length > 1 ? 's' : ''}`)
const messages = notificationMessages.get()
await clipboardWrite(xpaths.join('\n'), xpaths.length > 1 ? messages.xPaths : messages.xPath)
}
async function copyAsPNG() {
if (typeof ClipboardItem === 'undefined') {
toast.error('PNG clipboard export is not available in this browser')
toast.error(notificationMessages.get().pngClipboardUnavailable)
return
}
const data = await store.renderExportImage(ids(), 2, 'PNG')
if (!data) return
const blob = new Blob([toArrayBuffer(data)], { type: 'image/png' })
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
toast.info('Copied as PNG')
toast.info(notificationMessages.get().copiedAs({ format: 'PNG' }))
}
return {

View file

@ -1,4 +1,5 @@
import type { EditorStore } from '@/app/editor/active-store'
import { notificationMessages } from '@/app/i18n/notifications'
import { toast } from '@/app/shell/ui'
import { readTauriClipboardText } from '@/app/tauri/clipboard'
import { isTauri } from '@/app/tauri/env'
@ -26,12 +27,12 @@ export async function pasteClipboardToReplace(store: EditorStore) {
try {
const html = await readClipboardHTML()
if (!html) {
toast.error('Clipboard does not contain design data')
toast.error(notificationMessages.get().clipboardMissingDesignData)
return
}
await store.pasteFromHTML(html, undefined, { replaceSelection: true })
} catch (error) {
console.warn('Paste to replace failed', error)
toast.error('Clipboard access is blocked in this browser context')
toast.error(notificationMessages.get().clipboardAccessBlocked)
}
}

View file

@ -2,6 +2,7 @@ import { preprocessForVectorize, svgToVectorPaths } from '@open-pencil/core/vect
import type { Fill, SceneNode } from '@open-pencil/scene-graph'
import type { EditorStore } from '@/app/editor/active-store'
import { notificationMessages } from '@/app/i18n/notifications'
import { openSettingsDialog } from '@/app/settings/dialog'
import { toast } from '@/app/shell/ui'
@ -27,11 +28,10 @@ export function canVectorizeImageNode(store: EditorStore): boolean {
return Boolean(node && imageFill(node))
}
function vectorizeErrorMessage(error: unknown, providerName: string): string {
if (error instanceof TypeError) {
return `${providerName} could not be reached. Check your connection and try again.`
}
const message = error instanceof Error ? error.message : 'Vectorization failed'
function vectorizeErrorMessage(error: unknown): string {
let message = 'Vectorization failed'
if (error instanceof TypeError) message = error.message || 'Network request failed'
else if (error instanceof Error) message = error.message
return message.length <= ERROR_MAX_LENGTH ? message : `${message.slice(0, ERROR_MAX_LENGTH)}`
}
@ -46,18 +46,20 @@ export async function vectorizeImageNode(store: EditorStore, nodeId: string): Pr
try {
const apiKey = await resolveVectorizeCredential(provider.id)
if (!apiKey) {
toast.error(`Add a ${provider.name} API key in Settings → Media.`)
toast.error(
notificationMessages.get().vectorizeCredentialRequired({ provider: provider.name })
)
openSettingsDialog('media')
return
}
const imageBytes = store.graph.images.get(fill.imageHash)
if (!imageBytes) {
toast.error('Image data is missing for this layer')
toast.error(notificationMessages.get().vectorizeImageMissing)
return
}
toast.info('Vectorizing image…')
toast.info(notificationMessages.get().vectorizingImage)
const preprocessed = preprocessForVectorize(imageBytes, () => store.renderer?.ck ?? null)
if (!preprocessed) throw new Error('Could not prepare this image for vectorization')
@ -75,13 +77,17 @@ export async function vectorizeImageNode(store: EditorStore, nodeId: string): Pr
const frameId = store.replaceNodeWithVectorFrame(currentNode.id, vectorized)
if (!frameId) throw new Error('Could not replace this image with vector layers')
toast.info('Image converted to vectors')
toast.info(notificationMessages.get().imageConvertedToVectors)
} catch (error) {
if (error instanceof VectorizeAuthError) {
toast.error(`${error.message}. Update it in Settings → Media.`)
toast.error(notificationMessages.get().vectorizeCredentialFailed({ error: error.message }))
openSettingsDialog('media')
} else {
toast.error(vectorizeErrorMessage(error, provider.name))
toast.error(
notificationMessages
.get()
.vectorizeFailed({ provider: provider.name, error: vectorizeErrorMessage(error) })
)
}
} finally {
activeStores.delete(store)

View file

@ -0,0 +1,57 @@
import { createI18n, params } from '@nanostores/i18n'
import type { ComponentsJSON } from '@nanostores/i18n'
import { useStore } from '@nanostores/vue'
import { locale, type Locale, type TranslatedLocale } from '@open-pencil/vue'
export const notificationMessageDefaults = {
chatInitializationFailed: params('Could not initialize chat: {error}'),
linkCopied: 'Link copied to clipboard.',
clipboardMissingDesignData: 'Clipboard does not contain design data.',
clipboardAccessBlocked: 'Clipboard access is blocked in this browser context.',
copiedAs: params('Copied as {format}.'),
nodeID: 'node ID',
nodeIDs: 'node IDs',
xPath: 'XPath',
xPaths: 'XPaths',
pngClipboardUnavailable: 'PNG clipboard export is not available in this browser.',
openFileFailed: params('Could not open “{name}”: {error}'),
importedDOMCSS: 'Imported DOM/CSS document.',
importDOMCSSFailed: params('Could not import DOM/CSS: {error}'),
openDOMCSSFailed: params('Could not open DOM/CSS file: {error}'),
vectorizeCredentialRequired: params('Add a {provider} API key in Settings → Media.'),
vectorizeImageMissing: 'Image data is missing for this layer.',
vectorizingImage: 'Vectorizing image…',
imageConvertedToVectors: 'Image converted to vectors.',
vectorizeCredentialFailed: params('{error}. Update it in Settings → Media.'),
vectorizeFailed: params('{provider} could not vectorize this image: {error}'),
operationFailed: params('Operation failed: {error}'),
storageConnected: 'Connected. Storage namespace is ready.',
storageConnectionFailed: params('Could not connect to storage: {error}')
} as const
const localeLoaders = {
de: () => import('./locales/de.json'),
es: () => import('./locales/es.json'),
fr: () => import('./locales/fr.json'),
it: () => import('./locales/it.json'),
ja: () => import('./locales/ja.json'),
pl: () => import('./locales/pl.json'),
ru: () => import('./locales/ru.json'),
'zh-CN': () => import('./locales/zh-cn.json')
} satisfies Record<TranslatedLocale, () => Promise<{ default: Record<string, string> }>>
const appI18n = createI18n<Locale, 'en'>(locale, {
baseLocale: 'en',
async get(code): Promise<ComponentsJSON> {
if (code === 'en') return {}
const loaded = await localeLoaders[code]()
return { notifications: loaded.default }
}
})
export const notificationMessages = appI18n('notifications', notificationMessageDefaults)
export function useNotificationMessages() {
return useStore(notificationMessages)
}

View file

@ -0,0 +1,25 @@
{
"chatInitializationFailed": "Chat konnte nicht initialisiert werden: {error}",
"linkCopied": "Link in die Zwischenablage kopiert.",
"clipboardMissingDesignData": "Die Zwischenablage enthält keine Designdaten.",
"clipboardAccessBlocked": "Der Zugriff auf die Zwischenablage ist in diesem Browserkontext blockiert.",
"copiedAs": "Als {format} kopiert.",
"pngClipboardUnavailable": "Der PNG-Export in die Zwischenablage ist in diesem Browser nicht verfügbar.",
"openFileFailed": "„{name}“ konnte nicht geöffnet werden: {error}",
"importedDOMCSS": "DOM/CSS-Dokument importiert.",
"importDOMCSSFailed": "DOM/CSS konnte nicht importiert werden: {error}",
"openDOMCSSFailed": "DOM/CSS-Datei konnte nicht geöffnet werden: {error}",
"vectorizeCredentialRequired": "Füge einen {provider}-API-Schlüssel unter Einstellungen → Medien hinzu.",
"vectorizeImageMissing": "Für diese Ebene fehlen Bilddaten.",
"vectorizingImage": "Bild wird vektorisiert…",
"imageConvertedToVectors": "Bild in Vektoren umgewandelt.",
"vectorizeCredentialFailed": "{error}. Aktualisiere den Schlüssel unter Einstellungen → Medien.",
"vectorizeFailed": "{provider} konnte dieses Bild nicht vektorisieren: {error}",
"operationFailed": "Vorgang fehlgeschlagen: {error}",
"storageConnected": "Verbunden. Der Speicher-Namensraum ist bereit.",
"storageConnectionFailed": "Verbindung zum Speicher fehlgeschlagen: {error}",
"nodeID": "Knoten-ID",
"nodeIDs": "Knoten-IDs",
"xPath": "XPath",
"xPaths": "XPaths"
}

View file

@ -0,0 +1,25 @@
{
"chatInitializationFailed": "No se pudo iniciar el chat: {error}",
"linkCopied": "Enlace copiado al portapapeles.",
"clipboardMissingDesignData": "El portapapeles no contiene datos de diseño.",
"clipboardAccessBlocked": "El acceso al portapapeles está bloqueado en este contexto del navegador.",
"copiedAs": "Copiado como {format}.",
"pngClipboardUnavailable": "La exportación PNG al portapapeles no está disponible en este navegador.",
"openFileFailed": "No se pudo abrir «{name}»: {error}",
"importedDOMCSS": "Documento DOM/CSS importado.",
"importDOMCSSFailed": "No se pudo importar DOM/CSS: {error}",
"openDOMCSSFailed": "No se pudo abrir el archivo DOM/CSS: {error}",
"vectorizeCredentialRequired": "Añade una clave API de {provider} en Ajustes → Multimedia.",
"vectorizeImageMissing": "Faltan los datos de imagen de esta capa.",
"vectorizingImage": "Vectorizando imagen…",
"imageConvertedToVectors": "Imagen convertida en vectores.",
"vectorizeCredentialFailed": "{error}. Actualízala en Ajustes → Multimedia.",
"vectorizeFailed": "{provider} no pudo vectorizar esta imagen: {error}",
"operationFailed": "La operación falló: {error}",
"storageConnected": "Conectado. El espacio de almacenamiento está listo.",
"storageConnectionFailed": "No se pudo conectar al almacenamiento: {error}",
"nodeID": "ID de nodo",
"nodeIDs": "ID de nodos",
"xPath": "XPath",
"xPaths": "XPaths"
}

View file

@ -0,0 +1,25 @@
{
"chatInitializationFailed": "Impossible dinitialiser le chat : {error}",
"linkCopied": "Lien copié dans le presse-papiers.",
"clipboardMissingDesignData": "Le presse-papiers ne contient aucune donnée de design.",
"clipboardAccessBlocked": "Laccès au presse-papiers est bloqué dans ce contexte de navigateur.",
"copiedAs": "Copié au format {format}.",
"pngClipboardUnavailable": "Lexport PNG vers le presse-papiers nest pas disponible dans ce navigateur.",
"openFileFailed": "Impossible douvrir « {name} » : {error}",
"importedDOMCSS": "Document DOM/CSS importé.",
"importDOMCSSFailed": "Impossible dimporter le DOM/CSS : {error}",
"openDOMCSSFailed": "Impossible douvrir le fichier DOM/CSS : {error}",
"vectorizeCredentialRequired": "Ajoutez une clé API {provider} dans Réglages → Médias.",
"vectorizeImageMissing": "Les données dimage de ce calque sont manquantes.",
"vectorizingImage": "Vectorisation de limage…",
"imageConvertedToVectors": "Image convertie en vecteurs.",
"vectorizeCredentialFailed": "{error}. Mettez-la à jour dans Réglages → Médias.",
"vectorizeFailed": "{provider} na pas pu vectoriser cette image : {error}",
"operationFailed": "Échec de lopération : {error}",
"storageConnected": "Connexion établie. Lespace de stockage est prêt.",
"storageConnectionFailed": "Impossible de se connecter au stockage : {error}",
"nodeID": "ID de nœud",
"nodeIDs": "ID de nœuds",
"xPath": "XPath",
"xPaths": "XPaths"
}

View file

@ -0,0 +1,25 @@
{
"chatInitializationFailed": "Impossibile inizializzare la chat: {error}",
"linkCopied": "Link copiato negli appunti.",
"clipboardMissingDesignData": "Gli appunti non contengono dati di design.",
"clipboardAccessBlocked": "Laccesso agli appunti è bloccato in questo contesto del browser.",
"copiedAs": "Copiato come {format}.",
"pngClipboardUnavailable": "Lesportazione PNG negli appunti non è disponibile in questo browser.",
"openFileFailed": "Impossibile aprire «{name}»: {error}",
"importedDOMCSS": "Documento DOM/CSS importato.",
"importDOMCSSFailed": "Impossibile importare DOM/CSS: {error}",
"openDOMCSSFailed": "Impossibile aprire il file DOM/CSS: {error}",
"vectorizeCredentialRequired": "Aggiungi una chiave API {provider} in Impostazioni → Media.",
"vectorizeImageMissing": "Mancano i dati immagine per questo livello.",
"vectorizingImage": "Vettorializzazione dellimmagine…",
"imageConvertedToVectors": "Immagine convertita in vettori.",
"vectorizeCredentialFailed": "{error}. Aggiornala in Impostazioni → Media.",
"vectorizeFailed": "{provider} non ha potuto vettorializzare questa immagine: {error}",
"operationFailed": "Operazione non riuscita: {error}",
"storageConnected": "Connessione riuscita. Lo spazio di archiviazione è pronto.",
"storageConnectionFailed": "Impossibile connettersi allarchiviazione: {error}",
"nodeID": "ID nodo",
"nodeIDs": "ID nodi",
"xPath": "XPath",
"xPaths": "XPath"
}

View file

@ -0,0 +1,25 @@
{
"chatInitializationFailed": "チャットを初期化できませんでした: {error}",
"linkCopied": "リンクをクリップボードにコピーしました。",
"clipboardMissingDesignData": "クリップボードにデザインデータがありません。",
"clipboardAccessBlocked": "このブラウザー環境ではクリップボードへのアクセスがブロックされています。",
"copiedAs": "{format} としてコピーしました。",
"pngClipboardUnavailable": "このブラウザーでは PNG をクリップボードに書き出せません。",
"openFileFailed": "「{name}」を開けませんでした: {error}",
"importedDOMCSS": "DOM/CSS ドキュメントをインポートしました。",
"importDOMCSSFailed": "DOM/CSS をインポートできませんでした: {error}",
"openDOMCSSFailed": "DOM/CSS ファイルを開けませんでした: {error}",
"vectorizeCredentialRequired": "設定 → メディアで {provider} の API キーを追加してください。",
"vectorizeImageMissing": "このレイヤーの画像データがありません。",
"vectorizingImage": "画像をベクター化しています…",
"imageConvertedToVectors": "画像をベクターに変換しました。",
"vectorizeCredentialFailed": "{error}。設定 → メディアで更新してください。",
"vectorizeFailed": "{provider} でこの画像をベクター化できませんでした: {error}",
"operationFailed": "操作に失敗しました: {error}",
"storageConnected": "接続しました。ストレージ名前空間を使用できます。",
"storageConnectionFailed": "ストレージに接続できませんでした: {error}",
"nodeID": "ノード ID",
"nodeIDs": "ノード ID",
"xPath": "XPath",
"xPaths": "XPath"
}

View file

@ -0,0 +1,25 @@
{
"chatInitializationFailed": "Nie udało się uruchomić czatu: {error}",
"linkCopied": "Link skopiowano do schowka.",
"clipboardMissingDesignData": "Schowek nie zawiera danych projektu.",
"clipboardAccessBlocked": "Dostęp do schowka jest zablokowany w tym kontekście przeglądarki.",
"copiedAs": "Skopiowano jako {format}.",
"pngClipboardUnavailable": "Eksport PNG do schowka nie jest dostępny w tej przeglądarce.",
"openFileFailed": "Nie udało się otworzyć „{name}”: {error}",
"importedDOMCSS": "Zaimportowano dokument DOM/CSS.",
"importDOMCSSFailed": "Nie udało się zaimportować DOM/CSS: {error}",
"openDOMCSSFailed": "Nie udało się otworzyć pliku DOM/CSS: {error}",
"vectorizeCredentialRequired": "Dodaj klucz API {provider} w Ustawienia → Multimedia.",
"vectorizeImageMissing": "Brakuje danych obrazu dla tej warstwy.",
"vectorizingImage": "Wektoryzowanie obrazu…",
"imageConvertedToVectors": "Obraz przekonwertowano na wektory.",
"vectorizeCredentialFailed": "{error}. Zaktualizuj klucz w Ustawienia → Multimedia.",
"vectorizeFailed": "{provider} nie mógł zwektoryzować tego obrazu: {error}",
"operationFailed": "Operacja nie powiodła się: {error}",
"storageConnected": "Połączono. Przestrzeń przechowywania jest gotowa.",
"storageConnectionFailed": "Nie udało się połączyć z magazynem: {error}",
"nodeID": "ID węzła",
"nodeIDs": "ID węzłów",
"xPath": "XPath",
"xPaths": "XPath"
}

View file

@ -0,0 +1,25 @@
{
"chatInitializationFailed": "Не удалось запустить чат: {error}",
"linkCopied": "Ссылка скопирована в буфер обмена.",
"clipboardMissingDesignData": "В буфере обмена нет данных дизайна.",
"clipboardAccessBlocked": "Доступ к буферу обмена заблокирован в этом контексте браузера.",
"copiedAs": "Скопировано как {format}.",
"pngClipboardUnavailable": "Экспорт PNG в буфер обмена недоступен в этом браузере.",
"openFileFailed": "Не удалось открыть «{name}»: {error}",
"importedDOMCSS": "Документ DOM/CSS импортирован.",
"importDOMCSSFailed": "Не удалось импортировать DOM/CSS: {error}",
"openDOMCSSFailed": "Не удалось открыть файл DOM/CSS: {error}",
"vectorizeCredentialRequired": "Добавьте API-ключ {provider} в Настройки → Медиа.",
"vectorizeImageMissing": "Для этого слоя отсутствуют данные изображения.",
"vectorizingImage": "Векторизация изображения…",
"imageConvertedToVectors": "Изображение преобразовано в векторы.",
"vectorizeCredentialFailed": "{error}. Обновите ключ в Настройки → Медиа.",
"vectorizeFailed": "{provider} не смог векторизовать это изображение: {error}",
"operationFailed": "Не удалось выполнить операцию: {error}",
"storageConnected": "Подключено. Пространство хранилища готово.",
"storageConnectionFailed": "Не удалось подключиться к хранилищу: {error}",
"nodeID": "ID узла",
"nodeIDs": "ID узлов",
"xPath": "XPath",
"xPaths": "XPath"
}

View file

@ -0,0 +1,25 @@
{
"chatInitializationFailed": "无法初始化聊天:{error}",
"linkCopied": "链接已复制到剪贴板。",
"clipboardMissingDesignData": "剪贴板中没有设计数据。",
"clipboardAccessBlocked": "当前浏览器环境阻止了剪贴板访问。",
"copiedAs": "已复制为 {format}。",
"pngClipboardUnavailable": "此浏览器不支持将 PNG 导出到剪贴板。",
"openFileFailed": "无法打开“{name}”:{error}",
"importedDOMCSS": "已导入 DOM/CSS 文档。",
"importDOMCSSFailed": "无法导入 DOM/CSS{error}",
"openDOMCSSFailed": "无法打开 DOM/CSS 文件:{error}",
"vectorizeCredentialRequired": "请在设置 → 媒体中添加 {provider} API 密钥。",
"vectorizeImageMissing": "此图层缺少图像数据。",
"vectorizingImage": "正在矢量化图像…",
"imageConvertedToVectors": "图像已转换为矢量。",
"vectorizeCredentialFailed": "{error}。请在设置 → 媒体中更新。",
"vectorizeFailed": "{provider} 无法矢量化此图像:{error}",
"operationFailed": "操作失败:{error}",
"storageConnected": "已连接。存储命名空间已就绪。",
"storageConnectionFailed": "无法连接到存储:{error}",
"nodeID": "节点 ID",
"nodeIDs": "节点 ID",
"xPath": "XPath",
"xPaths": "XPath"
}

View file

@ -2,19 +2,25 @@ import { useFileDialog } from '@vueuse/core'
import { setOpenPencilOpenFileHandler } from '@/app/browser-bridge'
import { resolveBrowserFileURL } from '@/app/document/io/browser'
import { notificationMessages } from '@/app/i18n/notifications'
import { toast } from '@/app/shell/ui'
import { openFileInNewTab } from '@/app/tabs'
import { isTauri } from '@/app/tauri/env'
import { IS_BROWSER } from '@/constants'
const fileDialog = useFileDialog({
accept: '.fig,.pen,.html,.htm,.xhtml',
multiple: false,
multiple: true,
reset: true
})
fileDialog.onChange((files) => {
const file = files?.[0]
if (file) void openFileInNewTab(file)
if (!files) return
void openDesignFileBatch(
files,
(file) => file.name,
(file) => openFileInNewTab(file)
)
})
if (IS_BROWSER && 'window' in globalThis) {
@ -28,19 +34,37 @@ if (IS_BROWSER && 'window' in globalThis) {
})
}
export async function openDesignFileBatch<T>(
items: Iterable<T>,
displayName: (item: T) => string,
openItem: (item: T) => Promise<void>
): Promise<void> {
for (const item of items) {
try {
await openItem(item)
} catch (error) {
const name = displayName(item)
const detail = error instanceof Error ? error.message : String(error)
console.error(`Failed to open ${name}:`, error)
toast.error(notificationMessages.get().openFileFailed({ name, error: detail }))
}
}
}
export async function readTauriDesignFile(path: string): Promise<File> {
const { readFile } = await import('@tauri-apps/plugin-fs')
const bytes = await readFile(path)
return new File([bytes], path.split('/').pop() ?? 'file.fig')
}
export async function chooseTauriOpenPath(): Promise<string | null> {
export async function chooseTauriOpenPaths(): Promise<string[]> {
const { open } = await import('@tauri-apps/plugin-dialog')
const path = await open({
const paths = await open({
filters: [{ name: 'Design file', extensions: ['fig', 'pen', 'html', 'htm', 'xhtml'] }],
multiple: false
multiple: true
})
return typeof path === 'string' ? path : null
if (!paths) return []
return typeof paths === 'string' ? [paths] : paths
}
export async function openFileFromPath(path: string) {
@ -51,15 +75,15 @@ export async function openFileFromPath(path: string) {
export async function openFileDialog() {
if (isTauri()) {
const path = await chooseTauriOpenPath()
if (!path) return
await openFileFromPath(path)
const paths = await chooseTauriOpenPaths()
await openDesignFileBatch(paths, (path) => path.split(/[/\\]/).pop() ?? path, openFileFromPath)
return
}
if (window.showOpenFilePicker) {
try {
const [handle] = await window.showOpenFilePicker({
const handles = await window.showOpenFilePicker({
multiple: true,
types: [
{
description: 'Design file',
@ -73,8 +97,14 @@ export async function openFileDialog() {
}
]
})
const file = await handle.getFile()
await openFileInNewTab(file, handle)
await openDesignFileBatch(
handles,
(handle) => handle.name,
async (handle) => {
const file = await handle.getFile()
await openFileInNewTab(file, handle)
}
)
return
} catch (e) {
if ((e as Error).name === 'AbortError') return

View file

@ -14,7 +14,7 @@ const DEFAULT_THEME: AppTheme = 'dark'
const theme = useLocalStorage<AppTheme>(THEME_STORAGE_KEY, DEFAULT_THEME)
const prefersDark = usePreferredDark()
const resolvedTheme = computed<'dark' | 'light'>(() => {
export const resolvedAppTheme = computed<'dark' | 'light'>(() => {
if (theme.value === 'auto') return prefersDark.value ? 'dark' : 'light'
return theme.value
})
@ -47,16 +47,16 @@ function applyTheme(value: 'dark' | 'light', setting: AppTheme): void {
}
export function useAppTheme() {
watch([resolvedTheme, theme], ([value, setting]) => applyTheme(value, setting), {
watch([resolvedAppTheme, theme], ([value, setting]) => applyTheme(value, setting), {
immediate: true
})
// Editors may mount after the theme was applied; push the canvas (ruler)
// theme whenever the active editor changes so rulers always match.
const activeStoreRef = useActiveEditorStoreRef()
watch([activeStoreRef, resolvedTheme], () => updateCanvasTheme(), { flush: 'post' })
watch([activeStoreRef, resolvedAppTheme], () => updateCanvasTheme(), { flush: 'post' })
const isLight = computed(() => resolvedTheme.value === 'light')
const isLight = computed(() => resolvedAppTheme.value === 'light')
function setTheme(value: AppTheme): void {
theme.value = value
@ -66,7 +66,7 @@ export function useAppTheme() {
theme.value = isLight.value ? 'dark' : 'light'
}
return { theme, resolvedTheme, isLight, setTheme, toggleTheme }
return { theme, resolvedTheme: resolvedAppTheme, isLight, setTheme, toggleTheme }
}
applyTheme(resolvedTheme.value, theme.value)
applyTheme(resolvedAppTheme.value, theme.value)

View file

@ -33,6 +33,8 @@ import { useAIChat } from '@/app/ai/chat/use'
import { toast } from '@/app/shell/ui'
import { useI18n } from '@open-pencil/vue'
import { useNotificationMessages } from '@/app/i18n/notifications'
import type { Chat } from '@ai-sdk/vue'
import type { UIMessage } from 'ai'
import type { JSONObject } from '@open-pencil/scene-graph/primitives'
@ -42,6 +44,7 @@ const IS_DEV = import.meta.env.DEV
const { isConfigured, ensureChat, resetChat, chatFailure, clearChatFailure } = useAIChat()
const { copy } = useClipboard()
const { dialogs } = useI18n()
const notifications = useNotificationMessages()
const chat = ref<Chat<UIMessage> | null>(null)
const isPreparingImages = ref(false)
@ -53,7 +56,11 @@ void ensureChat()
return undefined
})
.catch((error: unknown) => {
toast.error(error instanceof Error ? error.message : 'Failed to initialize chat')
toast.error(
notifications.value.chatInitializationFailed({
error: error instanceof Error ? error.message : String(error)
})
)
})
const messagesEnd = ref<HTMLDivElement>()
const debugCopied = refAutoReset(false, 1500)
@ -73,6 +80,13 @@ const failureMessage = computed(() => {
}
})
const status = computed(() => chat.value?.status ?? 'ready')
function isStreamingMessage(message: UIMessage, index: number): boolean {
return (
message.role === 'assistant' &&
index === messages.value.length - 1 &&
(status.value === 'submitted' || status.value === 'streaming')
)
}
const isThinking = computed(() => {
const s = status.value
if (s !== 'submitted' && s !== 'streaming') return false
@ -254,7 +268,12 @@ function handleClearChat() {
<!-- Messages -->
<div v-else data-test-id="chat-messages" class="flex flex-col gap-3">
<ChatMessage v-for="msg in messages" :key="msg.id" :message="msg" />
<ChatMessage
v-for="(msg, index) in messages"
:key="msg.id"
:message="msg"
:streaming="isStreamingMessage(msg, index)"
/>
<!-- Thinking indicator: shown when AI is working but no visible activity -->
<div v-if="isThinking" data-test-id="chat-typing-indicator" class="flex gap-2">

View file

@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from '@open-pencil/vue'
import { DEFAULT_COLLAB_STATE, useCollabInjected } from '@/app/collab/use'
import { useNotificationMessages } from '@/app/i18n/notifications'
import { toast } from '@/app/shell/ui'
import { getShareURL } from '@/constants'
@ -15,6 +16,7 @@ function createCollabPanelContext() {
const collab = useCollabInjected()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
const { dialogs } = useI18n()
const notifications = useNotificationMessages()
const joinInput = ref('')
const nameDraft = ref(collab?.state.value.localName ?? '')
@ -42,7 +44,7 @@ function createCollabPanelContext() {
function copyLink() {
if (!shareURL.value) return
void copy(shareURL.value)
toast.info('Link copied to clipboard')
toast.info(notifications.value.linkCopied)
}
function share() {
@ -51,7 +53,7 @@ function createCollabPanelContext() {
const roomId = collab.shareCurrentDoc()
void router.push(`/share/${roomId}`)
void copy(getShareURL(roomId))
toast.info('Link copied to clipboard')
toast.info(notifications.value.linkCopied)
popoverOpen.value = false
}

View file

@ -13,6 +13,7 @@ import { useEditorCommands, useI18n } from '@open-pencil/vue'
import { DEFAULT_COLLAB_STATE, useCollabInjected } from '@/app/collab/use'
import { useEditorStore } from '@/app/editor/active-store'
import { toolIcons } from '@/app/editor/icons'
import { useNotificationMessages } from '@/app/i18n/notifications'
import { openFileDialog } from '@/app/shell/menu/use'
import { toast } from '@/app/shell/ui'
import type { ToolbarActionItem } from '@/components/Toolbar/types'
@ -26,6 +27,7 @@ function createMobileHudContext() {
const store = useEditorStore()
const { copy } = useClipboard()
const { dialogs } = useI18n()
const notifications = useNotificationMessages()
const { getCommand } = useEditorCommands()
const collabState = computed(() => collab?.state.value ?? DEFAULT_COLLAB_STATE)
@ -60,7 +62,7 @@ function createMobileHudContext() {
const roomId = collab.shareCurrentDoc()
void router.push(`/share/${roomId}`)
void copy(getShareURL(roomId))
toast.info('Link copied to clipboard')
toast.info(notifications.value.linkCopied)
}
function disconnect() {

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { isTextUIPart, isToolUIPart, getToolName } from 'ai'
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
import { Markdown } from 'vue-stream-markdown'
@ -9,12 +10,18 @@ import {
imageAttachmentsForMessage,
visibleUserMessageText
} from '@/app/ai/attachment/image/presentation'
import { resolvedAppTheme } from '@/app/shell/theme'
import ImageAttachment from '@/components/chat/attachment/image/ImageAttachment.vue'
import type { UIDataTypes, UIMessage, UIMessagePart, UITools } from 'ai'
const { message } = defineProps<{ message: UIMessage }>()
const { message, streaming = false } = defineProps<{
message: UIMessage
streaming?: boolean
}>()
const { dialogs } = useI18n()
const isDark = computed(() => resolvedAppTheme.value === 'dark')
const markdownMode = computed(() => (streaming ? 'streaming' : 'static'))
const imageAttachments = imageAttachmentsForMessage(message.id)
type ToolPart = Extract<UIMessagePart<UIDataTypes, UITools>, { toolCallId: string }>
@ -117,7 +124,15 @@ function partKey(part: UIMessagePart<UIDataTypes, UITools>, index: number): stri
data-test-id="chat-text-bubble"
class="rounded-xl rounded-tl-md bg-hover px-3 py-2 text-xs leading-relaxed text-surface"
>
<Markdown :content="part.text" :mermaid="false" class="chat-markdown" />
<Markdown
:key="markdownMode"
:content="part.text"
:is-dark="isDark"
:mermaid="false"
:mode="markdownMode"
:data-chat-markdown-mode="markdownMode"
class="chat-markdown [&_[data-stream-markdown=code]]:!bg-input"
/>
</div>
</template>
</template>

View file

@ -5,6 +5,8 @@ import { computed, ref, shallowRef, watch } from 'vue'
import { createLibraryUpdatePreview, type LibraryUpdatePreview } from '@open-pencil/core/library'
import { useI18n } from '@open-pencil/vue'
import { notificationMessages } from '@/app/i18n/notifications'
import { useEditorStore } from '@/app/editor/active-store'
import { closeLibraryReview, libraryReviewRequest, useLibraryService } from '@/app/libraries'
import { toast } from '@/app/shell/ui'
@ -31,6 +33,10 @@ interface ReviewOrigin {
let origin: ReviewOrigin | null = null
let accepted = false
let requestId = 0
const operationFailed = (cause: unknown) =>
notificationMessages.get().operationFailed({
error: cause instanceof Error ? cause.message : String(cause)
})
const open = computed({
get: () => libraryReviewRequest.value !== null,
set: (value) => {
@ -77,7 +83,7 @@ async function loadPreview() {
await focusCurrentInstance()
}
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : String(cause))
toast.error(operationFailed(cause))
} finally {
if (currentRequest === requestId) loading.value = false
}
@ -104,7 +110,7 @@ async function updateInstance() {
accepted = true
closeLibraryReview()
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : String(cause))
toast.error(operationFailed(cause))
} finally {
applying.value = false
}
@ -125,7 +131,7 @@ async function updateAll() {
accepted = true
closeLibraryReview()
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : String(cause))
toast.error(operationFailed(cause))
applying.value = false
}
}

View file

@ -1,6 +1,7 @@
import { computed, ref, watch, type Ref } from 'vue'
import type { EditorStore } from '@/app/editor/session'
import { notificationMessages } from '@/app/i18n/notifications'
import { createActiveStorageAdapter, storagePreferencesComplete } from '@/app/integrations/storage'
import { activeStorageProviderID } from '@/app/integrations/storage/preferences'
import {
@ -13,6 +14,10 @@ import type { LibraryAssetUpdateGroup } from '@/app/libraries/update-groups'
import { scopeLibraryUpdateGroups } from '@/app/libraries/update-groups'
import { toast } from '@/app/shell/ui'
function errorDetail(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause)
}
export function useLibraryManager(
open: Ref<boolean>,
editor: EditorStore,
@ -37,7 +42,7 @@ export function useLibraryManager(
if (request === requestId) updateGroups.value = groups
} catch (cause) {
if (request === requestId) {
toast.error(cause instanceof Error ? cause.message : String(cause))
toast.error(notificationMessages.get().operationFailed({ error: errorDetail(cause) }))
}
} finally {
if (request === requestId) loading.value = false
@ -48,7 +53,7 @@ export function useLibraryManager(
try {
await operation()
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : String(cause))
toast.error(notificationMessages.get().operationFailed({ error: errorDetail(cause) }))
}
}

View file

@ -4,6 +4,7 @@ import { libraryAssetKeyForComponent } from '@open-pencil/core/library'
import type { SceneNode } from '@open-pencil/scene-graph'
import type { EditorStore } from '@/app/editor/session'
import { notificationMessages } from '@/app/i18n/notifications'
import type { LibraryService } from '@/app/libraries'
import { toast } from '@/app/shell/ui'
@ -42,7 +43,11 @@ export function useInstanceUpdate(
await service.applyInstanceUpdate(editor, instance.id)
available.value = false
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : String(cause))
toast.error(
notificationMessages.get().operationFailed({
error: cause instanceof Error ? cause.message : String(cause)
})
)
} finally {
updating.value = false
}

View file

@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { AlertDialogCancel, AlertDialogDescription, AlertDialogTitle } from 'reka-ui'
import { useI18n } from '@open-pencil/vue'
import { useNotificationMessages } from '@/app/i18n/notifications'
import { discardRecoverySnapshot, listRecoverySnapshots, restoreRecoverySnapshot } from '@/app/tabs'
import type { RecoverySnapshotMeta } from '@/app/document/recovery'
import { formatStorageBytes } from '@/app/storage/format-bytes'
@ -11,6 +12,7 @@ import { toast } from '@/app/shell/ui'
import { AppAlertDialogRoot, AppDialogBody, AppDialogFooter } from '@/components/ui/dialog'
const { dialogs } = useI18n()
const notifications = useNotificationMessages()
const route = useRoute()
const snapshots = ref<RecoverySnapshotMeta[]>([])
const busyId = ref<string | null>(null)
@ -30,7 +32,11 @@ async function restore(snapshot: RecoverySnapshotMeta): Promise<void> {
snapshots.value = snapshots.value.filter((candidate) => candidate.id !== snapshot.id)
if (snapshots.value.length === 0) open.value = false
} catch (error) {
toast.error(error instanceof Error ? error.message : dialogs.value.recoveryFailed)
toast.error(
notifications.value.operationFailed({
error: error instanceof Error ? error.message : dialogs.value.recoveryFailed
})
)
} finally {
busyId.value = null
}

View file

@ -3,6 +3,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from '@open-pencil/vue'
import { useNotificationMessages } from '@/app/i18n/notifications'
import {
activeStorageProviderID,
createActiveStorageAdapter,
@ -21,6 +23,7 @@ import { resumeStorageSync } from '@/app/storage/sync'
import AppInput from '@/components/ui/AppInput.vue'
const { dialogs } = useI18n()
const notifications = useNotificationMessages()
const router = useRouter()
const provider = computed(() => storageProviderRegistry.get(activeStorageProviderID.value))
const preferenceDrafts = ref<Record<string, string>>({
@ -90,10 +93,14 @@ async function testConnection(): Promise<void> {
}
await resumeStorageSync()
const connection = await createActiveStorageAdapter(provider.value.id).testConnection()
if (connection.ok) toast.info(connection.message)
else toast.error(connection.message)
if (connection.ok) toast.info(notifications.value.storageConnected)
else toast.error(notifications.value.storageConnectionFailed({ error: connection.message }))
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
toast.error(
notifications.value.storageConnectionFailed({
error: error instanceof Error ? error.message : String(error)
})
)
} finally {
busy.value = false
}

1
src/global.d.ts vendored
View file

@ -14,6 +14,7 @@ declare global {
}
interface FilePickerOptions {
multiple?: boolean
types?: FilePickerAcceptType[]
suggestedName?: string
}

View file

@ -12,6 +12,7 @@ import { loadEditorLayout, saveEditorLayout } from '@/app/shell/layout-storage'
import { openFileFromPath, useEditorMenu } from '@/app/shell/menu/use'
import { useCollab, COLLAB_KEY } from '@/app/collab/use'
import { connectAutomation } from '@/app/automation/bridge/server'
import { exposeCollaborationActions } from '@/app/browser-bridge'
import { spawnMCPIfNeeded } from '@/app/automation/mcp/spawn'
import { isTauri } from '@/app/tauri/env'
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
@ -54,6 +55,7 @@ useEditorMenu()
const collab = useCollab(getActiveStore)
provide(COLLAB_KEY, collab)
exposeCollaborationActions(collab)
useEventListener(
document,

View file

@ -13,6 +13,12 @@ test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await page.goto('/')
await page.evaluate(async () => {
const themeModulePath = '/src/app/shell/theme.ts'
const themeModule = await import(themeModulePath)
themeModule.useAppTheme().setTheme('dark')
})
await page.waitForFunction(() => document.documentElement.dataset.theme === 'dark')
canvas = new CanvasHelper(page)
await canvas.waitForInit()
@ -43,6 +49,7 @@ async function injectMockTransport(page: Page) {
const msgId = `mock-msg-${++msgCounter}`
const lowerText = text.toLowerCase()
const wantsTool = lowerText.includes('frame') || lowerText.includes('rectangle')
const wantsCode = lowerText.includes('code block')
if (lowerText.includes('missing agent')) {
throw new Error(
@ -89,9 +96,10 @@ async function injectMockTransport(page: Page) {
})
}
const words = wantsTool
? ['Created', 'a', 'frame', 'called', '"Card".']
: `I'll help you with: "${text}". Here's a mock response.`.split(' ')
let words: string[]
if (wantsTool) words = ['Created', 'a', 'frame', 'called', '"Card".']
else if (wantsCode) words = ['```typescript\nconst greeting = "Hello"\n```']
else words = `I'll help you with: "${text}". Here's a mock response.`.split(' ')
controller.enqueue({ type: 'text-start', id: 'text-1' })
for (const word of words) {
@ -119,7 +127,7 @@ function designTab() {
}
function chatInput() {
return page.locator('textarea[placeholder="Describe a change…"]')
return page.getByRole('textbox', { name: 'Describe a change' })
}
function apiKeyInput() {
@ -253,6 +261,38 @@ test('assistant responds', async () => {
}
})
test('completed Markdown responses release streaming parser history', async () => {
await chatInput().fill('Show a code block')
await chatInput().press('Enter')
const markdown = page.locator('.chat-markdown').last()
await expect(markdown).toBeVisible()
await expect(markdown).toHaveAttribute('data-chat-markdown-mode', 'static')
await expect(markdown.locator('.shiki').first()).toBeVisible()
})
test('assistant code blocks follow the active theme with readable contrast', async () => {
await chatInput().fill('Show a code block')
await chatInput().press('Enter')
const code = page.getByTestId('chat-message-assistant').last().locator('.shiki').first()
await expect(code).toBeVisible()
await expect(page.locator('.chat-markdown').last()).toHaveClass(/dark/)
await expect(code).toHaveCSS('background-color', 'rgb(30, 30, 30)')
await expect(code.locator('span').filter({ hasText: 'const' }).first()).not.toHaveCSS(
'color',
'rgb(240, 240, 240)'
)
await page.evaluate(async () => {
const themeModulePath = '/src/app/shell/theme.ts'
const themeModule = await import(themeModulePath)
themeModule.useAppTheme().setTheme('light')
})
await page.waitForFunction(() => document.documentElement.dataset.theme === 'light')
await expect(page.locator('.chat-markdown').last()).toHaveClass(/light/)
await expect(code).toHaveCSS('background-color', 'rgb(255, 255, 255)')
})
test('model selector is visible and clickable', async () => {
const trigger = page.getByTestId('chat-model-selector')
await expect(trigger).toBeVisible()

View file

@ -0,0 +1,262 @@
import { expect, test, type Browser, type BrowserContext, type Page } from '@playwright/test'
import { WebSocketServer, type WebSocket } from 'ws'
import { CanvasHelper } from '#tests/helpers/canvas'
const ROOM_ID = 'e2e-collaboration-room'
type TestRelay = {
url: string
pause: () => void
resume: () => void
queuedCount: () => number
close: () => Promise<void>
}
async function startRelay(): Promise<TestRelay> {
const rooms = new Map<string, Set<WebSocket>>()
const sockets = new Map<WebSocket, { room: Set<WebSocket>; peerId: string | null }>()
const queuedMessages: Array<{ sender: WebSocket; room: Set<WebSocket>; text: string }> = []
let paused = false
const server = new WebSocketServer({ host: '127.0.0.1', port: 0 })
server.on('connection', (socket, request) => {
const roomId = new URL(request.url ?? '/', 'ws://127.0.0.1').searchParams.get('roomId') ?? ''
let room = rooms.get(roomId)
if (!room) {
room = new Set()
rooms.set(roomId, room)
}
room.add(socket)
sockets.set(socket, { room, peerId: null })
socket.on('message', (data) => {
const text = data.toString()
const message = JSON.parse(text) as { senderId?: string }
const state = sockets.get(socket)
if (state && message.senderId) state.peerId = message.senderId
if (paused) {
queuedMessages.push({ sender: socket, room, text })
return
}
for (const peer of room) {
if (peer !== socket && peer.readyState === peer.OPEN) peer.send(text)
}
})
socket.on('close', () => {
const state = sockets.get(socket)
room?.delete(socket)
sockets.delete(socket)
if (!state?.peerId) return
const leave = JSON.stringify({ type: 'leave', senderId: state.peerId })
for (const peer of state.room) if (peer.readyState === peer.OPEN) peer.send(leave)
})
})
await new Promise<void>((resolve, reject) => {
server.once('listening', () => resolve())
server.once('error', reject)
})
const address = server.address()
if (typeof address === 'string' || address === null) throw new Error('Test relay unavailable')
return {
url: `ws://127.0.0.1:${address.port}`,
pause: () => {
paused = true
},
resume: () => {
paused = false
for (const message of queuedMessages.splice(0)) {
for (const peer of message.room) {
if (peer !== message.sender && peer.readyState === peer.OPEN) peer.send(message.text)
}
}
},
queuedCount: () => queuedMessages.length,
close: async () => {
for (const room of rooms.values()) for (const socket of room) socket.terminate()
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error)
else resolve()
})
})
}
}
}
type Peer = {
context: BrowserContext
page: Page
canvas: CanvasHelper
}
async function createPeer(browser: Browser, name: string, relayURL: string): Promise<Peer> {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } })
try {
const page = await context.newPage()
await page.goto(`/?test&collabTransport=test&collabRelay=${encodeURIComponent(relayURL)}`)
await page.evaluate(
(localName) => window.openPencil?.test?.collab?.setLocalName(localName),
name
)
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
canvas.errors.length = 0
return { context, page, canvas }
} catch (error) {
await context.close()
throw error
}
}
function collaborationErrors(peer: Peer): string[] {
return peer.canvas.errors.filter((error) => !error.includes('127.0.0.1:7600'))
}
async function connect(peer: Peer) {
await peer.page.evaluate((roomId) => {
const collab = window.openPencil?.test?.collab
if (!collab) throw new Error('Collaboration bridge unavailable')
collab.connect(roomId)
}, ROOM_ID)
}
test('two browser peers synchronize editing, awareness, departure, and reconnect', async ({
browser
}) => {
test.setTimeout(120_000)
const relay = await startRelay()
let host: Peer | null = null
let guest: Peer | null = null
try {
host = await createPeer(browser, 'Host', relay.url)
guest = await createPeer(browser, 'Guest', relay.url)
await connect(host)
await connect(guest)
await expect
.poll(() => host.page.evaluate(() => window.openPencil?.test?.collab?.peerCount()))
.toBe(1)
await expect
.poll(() => guest.page.evaluate(() => window.openPencil?.test?.collab?.peerCount()))
.toBe(1)
const nodeId = await host.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const node = store.graph.createNode('RECTANGLE', store.state.currentPageId, {
name: 'Shared rectangle',
x: 160,
y: 140,
width: 120,
height: 80
})
store.requestRender()
return node.id
})
await expect
.poll(() =>
guest.page.evaluate((id) => window.openPencil?.getStore?.().graph.getNode(id)?.name, nodeId)
)
.toBe('Shared rectangle')
await guest.page.evaluate((id) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.updateNode(id, { name: 'Edited by Guest', x: 320 })
store.select([id])
window.openPencil?.test?.collab?.updateSelection([id])
}, nodeId)
await expect
.poll(() =>
host.page.evaluate((id) => window.openPencil?.getStore?.().graph.getNode(id)?.name, nodeId)
)
.toBe('Edited by Guest')
await expect
.poll(() => host.page.evaluate(() => window.openPencil?.test?.collab?.peerSelections()[0]))
.toEqual([nodeId])
relay.pause()
await guest.page.evaluate((id) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.updateNode(id, { y: 280 })
}, nodeId)
await host.page.evaluate((id) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.updateNode(id, { name: 'Host partition edit' })
}, nodeId)
await expect.poll(() => relay.queuedCount()).toBeGreaterThan(0)
relay.resume()
for (const peer of [host, guest]) {
await expect
.poll(() =>
peer.page.evaluate((id) => {
const node = window.openPencil?.getStore?.().graph.getNode(id)
return node ? { name: node.name, y: node.y } : null
}, nodeId)
)
.toEqual({ name: 'Host partition edit', y: 280 })
}
await guest.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
window.openPencil?.test?.collab?.updateCursor(420, 260, store.state.currentPageId)
})
await expect
.poll(() =>
host.page.evaluate(() => window.openPencil?.getStore?.().state.remoteCursors.length)
)
.toBe(1)
expect(collaborationErrors(guest)).toEqual([])
await guest.context.close()
guest = null
await expect
.poll(() => host.page.evaluate(() => window.openPencil?.test?.collab?.peerCount()))
.toBe(0)
await expect
.poll(() =>
host.page.evaluate(() => window.openPencil?.getStore?.().state.remoteCursors.length)
)
.toBe(0)
const reconnectingGuest = await createPeer(browser, 'Guest', relay.url)
try {
await host.page.evaluate((id) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.updateNode(id, { name: 'Edited while offline', y: 260 })
}, nodeId)
await connect(reconnectingGuest)
await expect
.poll(() =>
reconnectingGuest.page.evaluate(
(id) => window.openPencil?.getStore?.().graph.getNode(id)?.name,
nodeId
)
)
.toBe('Edited while offline')
await expect
.poll(() => host.page.evaluate(() => window.openPencil?.test?.collab?.peerCount()))
.toBe(1)
expect(collaborationErrors(reconnectingGuest)).toEqual([])
} finally {
await reconnectingGuest.context.close()
}
expect(collaborationErrors(host)).toEqual([])
} finally {
try {
await guest?.context.close()
} finally {
try {
await host?.context.close()
} finally {
await relay.close()
}
}
}
})

View file

@ -0,0 +1,45 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
const editor = useEditorSetup()
test('centered and bottom-aligned text selection follows the rendered text', async () => {
for (const [textAlignHorizontal, textAlignVertical] of [
['CENTER', 'CENTER'],
['RIGHT', 'CENTER'],
['LEFT', 'BOTTOM']
] as const) {
const geometry = await editor.page.evaluate(
({ horizontal, vertical }) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = store.createShape('TEXT', 300, 250, 220, 120)
store.graph.updateNode(id, {
text: 'Aligned text',
fontSize: 24,
textAlignHorizontal: horizontal,
textAlignVertical: vertical
})
store.select([id])
store.startTextEditing(id)
store.textEditor?.selectAll()
store.requestRender()
return { rects: store.textEditor?.getSelectionRects() ?? [] }
},
{ horizontal: textAlignHorizontal, vertical: textAlignVertical }
)
const selection = expectDefined(geometry.rects[0], `${textAlignVertical} selection rectangle`)
expect(selection.y).toBeGreaterThan(textAlignVertical === 'BOTTOM' ? 80 : 35)
expect(selection.y + selection.height).toBeLessThanOrEqual(120)
if (textAlignHorizontal === 'CENTER') expect(selection.x).toBeGreaterThan(20)
if (textAlignHorizontal === 'RIGHT') expect(selection.x).toBeGreaterThan(40)
await editor.page.keyboard.press('Escape')
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
const selectedId = store?.state.selectedIds.values().next().value
if (selectedId) store?.graph.deleteNode(selectedId)
store?.clearSelection()
})
}
})

View file

@ -0,0 +1,45 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
import { localeSetting } from '@open-pencil/vue'
import { openDesignFileBatch } from '@/app/shell/menu/files'
import { toast } from '@/app/shell/ui'
afterEach(() => {
toast.toasts.value = []
localeSetting.set(undefined)
})
describe('openDesignFileBatch', () => {
test('opens selected files sequentially in selection order', async () => {
const opened: string[] = []
const openItem = mock(async (name: string) => {
opened.push(name)
})
await openDesignFileBatch(['first.fig', 'second.pen'], (name) => name, openItem)
expect(opened).toEqual(['first.fig', 'second.pen'])
expect(openItem).toHaveBeenCalledTimes(2)
})
test('reports one failed file and continues opening later selections', async () => {
localeSetting.set('en')
const opened: string[] = []
const openItem = mock(async (name: string) => {
opened.push(name)
if (name === 'broken.fig') throw new Error('Invalid FIG container')
})
await expect(
openDesignFileBatch(['first.fig', 'broken.fig', 'last.pen'], (name) => name, openItem)
).resolves.toBeUndefined()
expect(opened).toEqual(['first.fig', 'broken.fig', 'last.pen'])
expect(toast.toasts.value).toHaveLength(1)
expect(toast.toasts.value[0]).toMatchObject({
message: 'Could not open “broken.fig”: Invalid FIG container',
variant: 'error'
})
})
})

View file

@ -64,8 +64,8 @@ const SPECS: FixtureSpec[] = [
thumbnailHeight: 239,
imageCount: 3,
figKiwiVersion: 101,
g1ExportSize: 597347,
g2ExportSize: 597347
g1ExportSize: 498456,
g2ExportSize: 498456
}
]

View file

@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test'
import { saveExportedFile } from '@/app/document/export/files'
import { watchTauriFile } from '@/app/document/io/watch-targets'
import { chooseTauriOpenPath, readTauriDesignFile } from '@/app/shell/menu/files'
import { chooseTauriOpenPaths, readTauriDesignFile } from '@/app/shell/menu/files'
import { clearTauriMocks, mockTauriIPC } from '#tests/helpers/tauri/mocks'
@ -12,19 +12,19 @@ afterEach(async () => {
})
describe('Tauri file actions', () => {
test('chooses a design file through plugin-dialog', async () => {
test('chooses multiple design files through plugin-dialog', async () => {
await mockTauriIPC((cmd, args) => {
expect(cmd).toBe('plugin:dialog|open')
expect(args).toEqual({
options: {
filters: [{ name: 'Design file', extensions: ['fig', 'pen', 'html', 'htm', 'xhtml'] }],
multiple: false
multiple: true
}
})
return '/tmp/design.fig'
return ['/tmp/design.fig', '/tmp/design.pen']
})
await expect(chooseTauriOpenPath()).resolves.toBe('/tmp/design.fig')
await expect(chooseTauriOpenPaths()).resolves.toEqual(['/tmp/design.fig', '/tmp/design.pen'])
})
test('reads a Tauri design file into a File object', async () => {

View file

@ -152,7 +152,10 @@ describe('Tauri updater helper', () => {
rawJson: '{}'
}
}
if (cmd === 'plugin:dialog|confirm') return true
if (cmd === 'plugin:dialog|message') {
const options = args as { buttons?: string }
if (options.buttons === 'OkCancel') return 'Ok'
}
if (cmd === 'plugin:updater|download_and_install') {
const onEvent = (args as { onEvent: { onmessage: (event: unknown) => void } }).onEvent
onEvent.onmessage({ event: 'Started', data: { contentLength: 10 } })
@ -165,14 +168,15 @@ describe('Tauri updater helper', () => {
expect(calls.map((call) => call.cmd)).toEqual([
'plugin:updater|check',
'plugin:dialog|confirm',
'plugin:dialog|message',
'plugin:updater|download_and_install',
'plugin:dialog|message',
'plugin:process|restart'
])
expect(calls[1]?.args).toMatchObject({
title: 'Update available',
kind: 'info'
kind: 'info',
buttons: 'OkCancel'
})
expect(calls[2]?.args).toMatchObject({ rid: 9 })
})

View file

@ -1,12 +1,16 @@
import { describe, test, expect } from 'bun:test'
import type { CanvasKit } from 'canvaskit-wasm'
import type { CanvasKit, Paragraph, RectWithDirection } from 'canvaskit-wasm'
import { TextEditor, type SceneNode } from '@open-pencil/core'
import { createDefaultNode } from '@open-pencil/scene-graph/node-defaults'
import { expectDefined } from '#tests/helpers/assert'
const mockCk = {} as CanvasKit
const mockCk = {
RectHeightStyle: { Max: 0 },
RectWidthStyle: { Tight: 0 }
} as Pick<CanvasKit, 'RectHeightStyle' | 'RectWidthStyle'> as CanvasKit
function createEditor(text = 'Hello World') {
const editor = new TextEditor(mockCk)
@ -19,6 +23,51 @@ function editorState(editor: TextEditor) {
return expectDefined(editor.state, 'text editor state')
}
function createParagraphEditor(textAlignVertical: SceneNode['textAlignVertical']) {
const hitTestYs: number[] = []
const rects = [{ rect: [4, 2, 14, 12] }] as RectWithDirection[]
const lineMetrics = {
endExcludingWhitespaces: 5,
height: 10,
left: 3,
startIndex: 0
}
const paragraph = {
delete: () => undefined,
getGlyphPositionAtCoordinate: (_x: number, y: number) => {
hitTestYs.push(y)
return { pos: Math.round(y) }
},
getHeight: () => 20,
getLineMetrics: () => [lineMetrics],
getLineMetricsAt: () => lineMetrics,
getLineNumberAt: () => 0,
getRectsForRange: () => rects
} as Pick<
Paragraph,
| 'delete'
| 'getGlyphPositionAtCoordinate'
| 'getHeight'
| 'getLineMetrics'
| 'getLineMetricsAt'
| 'getLineNumberAt'
| 'getRectsForRange'
> as Paragraph
const renderer = {
buildParagraph: () => paragraph,
fontGeneration: 1
}
const editor = new TextEditor(mockCk)
editor.setRenderer(renderer as Parameters<TextEditor['setRenderer']>[0])
const node = createDefaultNode(() => 'aligned-text', 'TEXT', {
height: 100,
text: 'Hello',
textAlignVertical
})
editor.start(node)
return { editor, hitTestYs }
}
describe('TextEditor', () => {
test('start initializes state', () => {
const { editor } = createEditor()
@ -111,6 +160,51 @@ describe('TextEditor', () => {
expect(editor.state?.text).toBe(' World')
})
test('offsets hit testing, caret, and selection for vertical alignment', () => {
for (const [alignment, offset] of [
['TOP', 0],
['CENTER', 40],
['BOTTOM', 80]
] as const) {
const { editor, hitTestYs } = createParagraphEditor(alignment)
editorState(editor).selectionAnchor = 0
editorState(editor).cursor = 5
expect(editor.getSelectionRects()).toEqual([{ x: 4, y: 2 + offset, width: 10, height: 10 }])
editorState(editor).selectionAnchor = null
expect(editor.getCaretRect()).toEqual({ x: 14, y0: 2 + offset, y1: 12 + offset })
editor.setCursorAt(4, offset + 3)
expect(hitTestYs.at(-1)).toBe(3)
expect(editorState(editor).cursor).toBe(3)
editor.selectWordAt(4, offset + 2)
expect(hitTestYs.at(-1)).toBe(2)
expect(editor.getSelectionRange()).toEqual([0, 5])
editor.selectLineAt(4, offset + 4)
expect(hitTestYs.at(-1)).toBe(4)
expect(editor.getSelectionRange()).toEqual([0, 5])
editorState(editor).selectionAnchor = null
editorState(editor).cursor = 1
editor.moveUp()
expect(hitTestYs.at(-1)).toBe(-3)
editorState(editor).cursor = 1
editor.moveDown()
expect(hitTestYs.at(-1)).toBe(17)
}
})
test('offsets the empty-text caret for vertical alignment', () => {
const { editor } = createParagraphEditor('BOTTOM')
editorState(editor).text = ''
editorState(editor).cursor = 0
expect(editor.getCaretRect()).toEqual({ x: 3, y0: 80, y1: 90 })
})
test('moveLeft', () => {
const { editor } = createEditor()
editor.moveLeft()