Merge origin/master into feat/configurable-snapping-guides
This commit is contained in:
commit
42b1fa07f0
30
CHANGELOG.md
30
CHANGELOG.md
|
|
@ -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 manager’s 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
|
||||
|
||||
|
|
|
|||
2173
desktop/Cargo.lock
generated
2173
desktop/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
138
package.json
138
package.json
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] => [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
src/app/collab/transport/index.ts
Normal file
15
src/app/collab/transport/index.ts
Normal 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'
|
||||
159
src/app/collab/transport/test.ts
Normal file
159
src/app/collab/transport/test.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/app/collab/transport/trystero.ts
Normal file
45
src/app/collab/transport/trystero.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/app/collab/transport/types.ts
Normal file
14
src/app/collab/transport/types.ts
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
57
src/app/i18n/notifications/index.ts
Normal file
57
src/app/i18n/notifications/index.ts
Normal 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)
|
||||
}
|
||||
25
src/app/i18n/notifications/locales/de.json
Normal file
25
src/app/i18n/notifications/locales/de.json
Normal 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"
|
||||
}
|
||||
25
src/app/i18n/notifications/locales/es.json
Normal file
25
src/app/i18n/notifications/locales/es.json
Normal 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"
|
||||
}
|
||||
25
src/app/i18n/notifications/locales/fr.json
Normal file
25
src/app/i18n/notifications/locales/fr.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"chatInitializationFailed": "Impossible d’initialiser le chat : {error}",
|
||||
"linkCopied": "Lien copié dans le presse-papiers.",
|
||||
"clipboardMissingDesignData": "Le presse-papiers ne contient aucune donnée de design.",
|
||||
"clipboardAccessBlocked": "L’accès au presse-papiers est bloqué dans ce contexte de navigateur.",
|
||||
"copiedAs": "Copié au format {format}.",
|
||||
"pngClipboardUnavailable": "L’export PNG vers le presse-papiers n’est pas disponible dans ce navigateur.",
|
||||
"openFileFailed": "Impossible d’ouvrir « {name} » : {error}",
|
||||
"importedDOMCSS": "Document DOM/CSS importé.",
|
||||
"importDOMCSSFailed": "Impossible d’importer le DOM/CSS : {error}",
|
||||
"openDOMCSSFailed": "Impossible d’ouvrir le fichier DOM/CSS : {error}",
|
||||
"vectorizeCredentialRequired": "Ajoutez une clé API {provider} dans Réglages → Médias.",
|
||||
"vectorizeImageMissing": "Les données d’image de ce calque sont manquantes.",
|
||||
"vectorizingImage": "Vectorisation de l’image…",
|
||||
"imageConvertedToVectors": "Image convertie en vecteurs.",
|
||||
"vectorizeCredentialFailed": "{error}. Mettez-la à jour dans Réglages → Médias.",
|
||||
"vectorizeFailed": "{provider} n’a pas pu vectoriser cette image : {error}",
|
||||
"operationFailed": "Échec de l’opération : {error}",
|
||||
"storageConnected": "Connexion établie. L’espace 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"
|
||||
}
|
||||
25
src/app/i18n/notifications/locales/it.json
Normal file
25
src/app/i18n/notifications/locales/it.json
Normal 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": "L’accesso agli appunti è bloccato in questo contesto del browser.",
|
||||
"copiedAs": "Copiato come {format}.",
|
||||
"pngClipboardUnavailable": "L’esportazione 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 dell’immagine…",
|
||||
"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 all’archiviazione: {error}",
|
||||
"nodeID": "ID nodo",
|
||||
"nodeIDs": "ID nodi",
|
||||
"xPath": "XPath",
|
||||
"xPaths": "XPath"
|
||||
}
|
||||
25
src/app/i18n/notifications/locales/ja.json
Normal file
25
src/app/i18n/notifications/locales/ja.json
Normal 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"
|
||||
}
|
||||
25
src/app/i18n/notifications/locales/pl.json
Normal file
25
src/app/i18n/notifications/locales/pl.json
Normal 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"
|
||||
}
|
||||
25
src/app/i18n/notifications/locales/ru.json
Normal file
25
src/app/i18n/notifications/locales/ru.json
Normal 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"
|
||||
}
|
||||
25
src/app/i18n/notifications/locales/zh-cn.json
Normal file
25
src/app/i18n/notifications/locales/zh-cn.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1
src/global.d.ts
vendored
|
|
@ -14,6 +14,7 @@ declare global {
|
|||
}
|
||||
|
||||
interface FilePickerOptions {
|
||||
multiple?: boolean
|
||||
types?: FilePickerAcceptType[]
|
||||
suggestedName?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
262
tests/e2e/collab/session.spec.ts
Normal file
262
tests/e2e/collab/session.spec.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
45
tests/e2e/text/aligned-selection.spec.ts
Normal file
45
tests/e2e/text/aligned-selection.spec.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
})
|
||||
45
tests/engine/app/shell/menu/file-batch.test.ts
Normal file
45
tests/engine/app/shell/menu/file-batch.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -64,8 +64,8 @@ const SPECS: FixtureSpec[] = [
|
|||
thumbnailHeight: 239,
|
||||
imageCount: 3,
|
||||
figKiwiVersion: 101,
|
||||
g1ExportSize: 597347,
|
||||
g2ExportSize: 597347
|
||||
g1ExportSize: 498456,
|
||||
g2ExportSize: 498456
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue