Merge remote-tracking branch 'origin/master' into reference-image-chat
This commit is contained in:
commit
16e7707d7a
34
.devcontainer/Dockerfile
Normal file
34
.devcontainer/Dockerfile
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
FROM oven/bun@sha256:b86c67b531d87b4db11470d9b2bd0c519b1976eee6fcd71634e73abfa6230d2e
|
||||
|
||||
# Bun 1.3.10, pinned to the multi-platform image digest.
|
||||
|
||||
ARG CA_CERTIFICATES_VERSION=20250419
|
||||
ARG GIT_VERSION=1:2.47.3-0+deb13u1
|
||||
ARG GIT_LFS_VERSION=3.6.1-1+deb13u1
|
||||
ARG OPENSSH_CLIENT_VERSION=1:10.0p1-7+deb13u4
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends \
|
||||
ca-certificates=${CA_CERTIFICATES_VERSION} \
|
||||
git=${GIT_VERSION} \
|
||||
git-lfs=${GIT_LFS_VERSION} \
|
||||
openssh-client=${OPENSSH_CLIENT_VERSION} \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& git lfs install --system \
|
||||
&& install -d -o bun -g bun \
|
||||
/workspace/open-pencil/node_modules \
|
||||
/workspace/open-pencil/packages/scene-graph/node_modules \
|
||||
/workspace/open-pencil/packages/pen/node_modules \
|
||||
/workspace/open-pencil/packages/kiwi/node_modules \
|
||||
/workspace/open-pencil/packages/fig/node_modules \
|
||||
/workspace/open-pencil/packages/core/node_modules \
|
||||
/workspace/open-pencil/packages/dom-css/node_modules \
|
||||
/workspace/open-pencil/packages/vue/node_modules \
|
||||
/workspace/open-pencil/packages/cli/node_modules \
|
||||
/workspace/open-pencil/packages/mcp/node_modules \
|
||||
/workspace/open-pencil/packages/docs/node_modules \
|
||||
/workspace/open-pencil/tools/docs/node_modules
|
||||
|
||||
USER bun
|
||||
37
.devcontainer/devcontainer.json
Normal file
37
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json",
|
||||
"name": "OpenPencil",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": "."
|
||||
},
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace/open-pencil,type=bind,consistency=cached",
|
||||
"workspaceFolder": "/workspace/open-pencil",
|
||||
"remoteUser": "bun",
|
||||
"updateRemoteUserUID": true,
|
||||
"mounts": [
|
||||
"source=open-pencil-root-modules-${devcontainerId},target=/workspace/open-pencil/node_modules,type=volume",
|
||||
"source=open-pencil-scene-graph-modules-${devcontainerId},target=/workspace/open-pencil/packages/scene-graph/node_modules,type=volume",
|
||||
"source=open-pencil-pen-modules-${devcontainerId},target=/workspace/open-pencil/packages/pen/node_modules,type=volume",
|
||||
"source=open-pencil-kiwi-modules-${devcontainerId},target=/workspace/open-pencil/packages/kiwi/node_modules,type=volume",
|
||||
"source=open-pencil-fig-modules-${devcontainerId},target=/workspace/open-pencil/packages/fig/node_modules,type=volume",
|
||||
"source=open-pencil-core-modules-${devcontainerId},target=/workspace/open-pencil/packages/core/node_modules,type=volume",
|
||||
"source=open-pencil-dom-css-modules-${devcontainerId},target=/workspace/open-pencil/packages/dom-css/node_modules,type=volume",
|
||||
"source=open-pencil-vue-modules-${devcontainerId},target=/workspace/open-pencil/packages/vue/node_modules,type=volume",
|
||||
"source=open-pencil-cli-modules-${devcontainerId},target=/workspace/open-pencil/packages/cli/node_modules,type=volume",
|
||||
"source=open-pencil-mcp-modules-${devcontainerId},target=/workspace/open-pencil/packages/mcp/node_modules,type=volume",
|
||||
"source=open-pencil-docs-modules-${devcontainerId},target=/workspace/open-pencil/packages/docs/node_modules,type=volume",
|
||||
"source=open-pencil-docs-tool-modules-${devcontainerId},target=/workspace/open-pencil/tools/docs/node_modules,type=volume"
|
||||
],
|
||||
"postCreateCommand": "if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then git lfs pull; fi && bun install --frozen-lockfile",
|
||||
"containerEnv": {
|
||||
"TAURI_DEV_HOST": "0.0.0.0"
|
||||
},
|
||||
"forwardPorts": [1420],
|
||||
"portsAttributes": {
|
||||
"1420": {
|
||||
"label": "OpenPencil web editor",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
### Added
|
||||
|
||||
- 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)
|
||||
|
|
@ -12,10 +13,14 @@
|
|||
|
||||
### Performance
|
||||
|
||||
- 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)
|
||||
- Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 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)
|
||||
|
|
@ -30,6 +35,7 @@
|
|||
- Remove the permanent CORS configuration action from cloud-storage settings and report connection results through standard toasts with clear browser-specific guidance.
|
||||
- Complete translated app, accessibility, font, color, collaboration, import, connection-test, and browser fallback text across all supported locales, and keep the document language synchronized with the selected locale.
|
||||
- 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)
|
||||
|
||||
## 0.14.0 — 2026-08-10
|
||||
|
|
|
|||
|
|
@ -255,6 +255,10 @@ bun run dev # Dev server at localhost:1420
|
|||
bun run tauri dev # Desktop app (requires Rust)
|
||||
```
|
||||
|
||||
Alternatively, open the repository in any [Dev Container](https://containers.dev/)-compatible tool. The container pins Bun, installs the workspace dependencies, and forwards the web editor on port 1420. Start it with `bun run dev` after the container is ready.
|
||||
|
||||
The Dev Container supports the web editor, packages, CLI, and automated checks. Native Tauri development still requires the host setup described below because desktop windows and platform WebView dependencies are not provided in the container.
|
||||
|
||||
### Quality gates
|
||||
|
||||
| Command | Description |
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ pub fn build_fig_file(
|
|||
meta_json: String,
|
||||
images: Option<Vec<ImageEntry>>,
|
||||
fig_kiwi_version: Option<u32>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
) -> Result<tauri::ipc::Response, String> {
|
||||
let mut encoder = zstd::Encoder::new(Vec::new(), 3).map_err(|e| e.to_string())?;
|
||||
encoder
|
||||
.include_contentsize(true)
|
||||
|
|
@ -63,5 +63,5 @@ pub fn build_fig_file(
|
|||
}
|
||||
|
||||
let result = zip.finish().map_err(|e| e.to_string())?;
|
||||
Ok(result.into_inner())
|
||||
Ok(tauri::ipc::Response::new(result.into_inner()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,25 +139,31 @@ export function figmaNodesBounds(
|
|||
interface ClipboardImportMaps {
|
||||
guidMap: Map<string, KiwiNodeChange>
|
||||
parentMap: Map<string, string>
|
||||
childMap: Map<string, string[]>
|
||||
}
|
||||
|
||||
function buildClipboardMaps(nodeChanges: KiwiNodeChange[]): ClipboardImportMaps {
|
||||
const guidMap = new Map<string, KiwiNodeChange>()
|
||||
const parentMap = new Map<string, string>()
|
||||
const childMap = new Map<string, string[]>()
|
||||
for (const nc of nodeChanges) {
|
||||
if (!nc.guid) continue
|
||||
const id = `${nc.guid.sessionID}:${nc.guid.localID}`
|
||||
guidMap.set(id, nc)
|
||||
if (nc.parentIndex?.guid) {
|
||||
parentMap.set(id, `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}`)
|
||||
const parentId = `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}`
|
||||
parentMap.set(id, parentId)
|
||||
const siblings = childMap.get(parentId)
|
||||
if (siblings) siblings.push(id)
|
||||
else childMap.set(parentId, [id])
|
||||
}
|
||||
}
|
||||
return { guidMap, parentMap }
|
||||
return { guidMap, parentMap, childMap }
|
||||
}
|
||||
|
||||
function findInternalNodeIds(
|
||||
guidMap: Map<string, KiwiNodeChange>,
|
||||
parentMap: Map<string, string>
|
||||
childMap: Map<string, string[]>
|
||||
): { internalCanvasIds: Set<string>; internalFigmaIds: Set<string> } {
|
||||
const internalCanvasIds = new Set<string>()
|
||||
for (const [id, nc] of guidMap) {
|
||||
|
|
@ -169,8 +175,8 @@ function findInternalNodeIds(
|
|||
const internalFigmaIds = new Set<string>()
|
||||
function markInternal(id: string) {
|
||||
internalFigmaIds.add(id)
|
||||
for (const [childId, pid] of parentMap) {
|
||||
if (pid === id && !internalFigmaIds.has(childId)) markInternal(childId)
|
||||
for (const childId of childMap.get(id) ?? []) {
|
||||
if (!internalFigmaIds.has(childId)) markInternal(childId)
|
||||
}
|
||||
}
|
||||
for (const canvasId of internalCanvasIds) markInternal(canvasId)
|
||||
|
|
@ -230,8 +236,8 @@ export function importClipboardNodes(
|
|||
offsetY = 0,
|
||||
blobs: Uint8Array[] = []
|
||||
): string[] {
|
||||
const { guidMap, parentMap } = buildClipboardMaps(nodeChanges)
|
||||
const { internalCanvasIds, internalFigmaIds } = findInternalNodeIds(guidMap, parentMap)
|
||||
const { guidMap, parentMap, childMap } = buildClipboardMaps(nodeChanges)
|
||||
const { internalCanvasIds, internalFigmaIds } = findInternalNodeIds(guidMap, childMap)
|
||||
const { topLevel, internalTopLevel } = classifyTopLevelNodes(
|
||||
guidMap,
|
||||
parentMap,
|
||||
|
|
@ -262,12 +268,9 @@ export function importClipboardNodes(
|
|||
created.set(figmaId, node.id)
|
||||
if (ourParentId === targetParentId && !internalFigmaIds.has(figmaId)) createdIds.push(node.id)
|
||||
|
||||
const children: string[] = []
|
||||
for (const [childId, pid] of parentMap) {
|
||||
if (pid === figmaId && !NON_VISUAL_TYPES.has(guidMap.get(childId)?.type ?? '')) {
|
||||
children.push(childId)
|
||||
}
|
||||
}
|
||||
const children = (childMap.get(figmaId) ?? []).filter(
|
||||
(childId) => !NON_VISUAL_TYPES.has(guidMap.get(childId)?.type ?? '')
|
||||
)
|
||||
sortChildren(children, nc, guidMap)
|
||||
for (const childId of children) {
|
||||
createNode(childId, node.id)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type { Vector } from '@open-pencil/scene-graph/primitives'
|
|||
|
||||
import { parseSVGFragment } from '#core/io/formats/svg/document'
|
||||
|
||||
import type { IconData, IconifyIconEntry, IconPathInfo } from './types'
|
||||
import type { IconData, IconifyIconEntry, IconPathInfo, SVGClipPathRegion } from './types'
|
||||
|
||||
interface SVGElementInput {
|
||||
type: string
|
||||
|
|
@ -185,6 +185,7 @@ function appendShapePath(
|
|||
element: Element,
|
||||
presentation: PresentationAttributes,
|
||||
transform: string | null,
|
||||
clipPaths: SVGClipPathRegion[],
|
||||
result: IconPathInfo[]
|
||||
): void {
|
||||
if (!SHAPE_NAMES.has(tagName)) return
|
||||
|
|
@ -199,7 +200,8 @@ function appendShapePath(
|
|||
strokeCap: presentation.strokeCap,
|
||||
strokeJoin: presentation.strokeJoin,
|
||||
fillRule: presentation.fillRule === 'evenodd' ? 'EVENODD' : 'NONZERO',
|
||||
transform
|
||||
transform,
|
||||
clipPaths: clipPaths.length > 0 ? clipPaths : undefined
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -209,7 +211,8 @@ function collectUsePaths(
|
|||
transform: string | null,
|
||||
result: IconPathInfo[],
|
||||
elementsById: ReadonlyMap<string, Element>,
|
||||
useStack: ReadonlySet<Element>
|
||||
useStack: ReadonlySet<Element>,
|
||||
clipPaths: SVGClipPathRegion[]
|
||||
): boolean {
|
||||
const tagName = element.localName || element.tagName
|
||||
if (tagName !== 'use') return false
|
||||
|
|
@ -227,12 +230,42 @@ function collectUsePaths(
|
|||
result,
|
||||
elementsById,
|
||||
new Set([...useStack, target]),
|
||||
true
|
||||
true,
|
||||
clipPaths
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function collectClipPath(
|
||||
value: string | null,
|
||||
parentTransform: string | null,
|
||||
elementsById: ReadonlyMap<string, Element>
|
||||
): SVGClipPathRegion | null {
|
||||
const match = value?.trim().match(/^url\(\s*['"]?#([^'")\s]+)['"]?\s*\)$/)
|
||||
const target = match ? elementsById.get(match[1]) : null
|
||||
if (!target || (target.localName || target.tagName) !== 'clipPath') return null
|
||||
|
||||
const units =
|
||||
target.getAttribute('clipPathUnits') === 'objectBoundingBox'
|
||||
? 'objectBoundingBox'
|
||||
: 'userSpaceOnUse'
|
||||
const paths: IconPathInfo[] = []
|
||||
collectPaths(
|
||||
target,
|
||||
{ ...DEFAULT_PRESENTATION, fill: '#000000' },
|
||||
units === 'objectBoundingBox' ? null : parentTransform,
|
||||
paths,
|
||||
elementsById,
|
||||
new Set([target]),
|
||||
true
|
||||
)
|
||||
return {
|
||||
paths: paths.map(({ d, fillRule, transform }) => ({ d, fillRule, transform })),
|
||||
units
|
||||
}
|
||||
}
|
||||
|
||||
function collectPaths(
|
||||
element: Element,
|
||||
inherited: PresentationAttributes,
|
||||
|
|
@ -240,19 +273,32 @@ function collectPaths(
|
|||
result: IconPathInfo[],
|
||||
elementsById: ReadonlyMap<string, Element>,
|
||||
useStack: ReadonlySet<Element> = new Set(),
|
||||
referenced = false
|
||||
referenced = false,
|
||||
inheritedClipPaths: SVGClipPathRegion[] = []
|
||||
): void {
|
||||
const tagName = element.localName || element.tagName
|
||||
if (NON_RENDERED_CONTAINERS.has(tagName) && !referenced) return
|
||||
|
||||
const presentation = presentationFor(element, inherited)
|
||||
const transform = combinedTransform(parentTransform, element)
|
||||
if (collectUsePaths(element, presentation, transform, result, elementsById, useStack)) return
|
||||
appendShapePath(tagName, element, presentation, transform, result)
|
||||
const ownClipPath = collectClipPath(element.getAttribute('clip-path'), transform, elementsById)
|
||||
const clipPaths = ownClipPath ? [...inheritedClipPaths, ownClipPath] : inheritedClipPaths
|
||||
if (collectUsePaths(element, presentation, transform, result, elementsById, useStack, clipPaths))
|
||||
return
|
||||
appendShapePath(tagName, element, presentation, transform, clipPaths, result)
|
||||
|
||||
for (const child of Array.from(element.childNodes)) {
|
||||
if (isElement(child)) {
|
||||
collectPaths(child, presentation, transform, result, elementsById, useStack, referenced)
|
||||
collectPaths(
|
||||
child,
|
||||
presentation,
|
||||
transform,
|
||||
result,
|
||||
elementsById,
|
||||
useStack,
|
||||
referenced,
|
||||
clipPaths
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,15 @@ export interface IconPathInfo {
|
|||
strokeCap: string
|
||||
strokeJoin: string
|
||||
fillRule: WindingRule
|
||||
/** Nested SVG clip regions, ordered from outermost to innermost. */
|
||||
clipPaths?: SVGClipPathRegion[]
|
||||
/** Raw transform attribute from the source SVG element. */
|
||||
transform?: string | null
|
||||
}
|
||||
|
||||
export type SVGClipPathInfo = Pick<IconPathInfo, 'd' | 'fillRule' | 'transform'>
|
||||
|
||||
export interface SVGClipPathRegion {
|
||||
paths: SVGClipPathInfo[]
|
||||
units: 'userSpaceOnUse' | 'objectBoundingBox'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -554,7 +554,7 @@ export async function exportFigFile(
|
|||
if (IS_TAURI) {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return new Uint8Array(
|
||||
await invoke<number[]>('build_fig_file', {
|
||||
await invoke<ArrayBuffer>('build_fig_file', {
|
||||
schemaDeflated: Array.from(schemaDeflated),
|
||||
kiwiData: Array.from(kiwiData),
|
||||
thumbnailPng: Array.from(thumbnailPNG),
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ interface NormalizedVectorGeometry {
|
|||
bounds: Rect
|
||||
}
|
||||
|
||||
type VectorChildPaints = Pick<SceneNode, 'fillGeometry' | 'fills' | 'strokes'>
|
||||
type VectorChildPaints = Pick<SceneNode, 'fillGeometry' | 'fills' | 'strokes'> &
|
||||
Partial<Pick<SceneNode, 'isMask' | 'maskType'>>
|
||||
|
||||
function shouldTightenToContent(
|
||||
node: Pick<SceneNode, 'width' | 'height' | 'rotation'>,
|
||||
|
|
@ -206,6 +207,64 @@ export function createVectorFrameChildren(
|
|||
}
|
||||
}
|
||||
|
||||
function createClipFrame(
|
||||
graph: SceneGraph,
|
||||
frameId: string,
|
||||
placement: VectorFramePlacement,
|
||||
index: number
|
||||
): SceneNode {
|
||||
return graph.createNode('FRAME', frameId, {
|
||||
name: `clip ${index + 1}`,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: placement.width,
|
||||
height: placement.height,
|
||||
fills: []
|
||||
})
|
||||
}
|
||||
|
||||
function createClipMaskChild(
|
||||
graph: SceneGraph,
|
||||
frameId: string,
|
||||
clipNetwork: VectorNetwork,
|
||||
placement: VectorFramePlacement,
|
||||
index: number
|
||||
): void {
|
||||
const network = offsetVectorNetwork(clipNetwork, placement.offsetX, placement.offsetY)
|
||||
const normalized = normalizeVectorToNodeBounds(network)
|
||||
if (!normalized) return
|
||||
createNormalizedVectorChild(graph, frameId, normalized, index, {
|
||||
fillGeometry: [],
|
||||
fills: [
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: { r: 1, g: 1, b: 1, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true
|
||||
}
|
||||
],
|
||||
strokes: [],
|
||||
isMask: true,
|
||||
maskType: 'VECTOR'
|
||||
})
|
||||
}
|
||||
|
||||
function createClipFrames(
|
||||
graph: SceneGraph,
|
||||
frameId: string,
|
||||
clipNetworks: VectorNetwork[],
|
||||
placement: VectorFramePlacement,
|
||||
index: number
|
||||
): string {
|
||||
let targetFrameId = frameId
|
||||
for (const clipNetwork of clipNetworks) {
|
||||
const clipFrame = createClipFrame(graph, targetFrameId, placement, index)
|
||||
createClipMaskChild(graph, clipFrame.id, clipNetwork, placement, index)
|
||||
targetFrameId = clipFrame.id
|
||||
}
|
||||
return targetFrameId
|
||||
}
|
||||
|
||||
function isFlattenableVectorPath(path: VectorizedPath): boolean {
|
||||
return path.fills.length > 0 && path.strokes.length === 0 && path.vectorNetwork.regions.length > 0
|
||||
}
|
||||
|
|
@ -218,28 +277,42 @@ export function createFlattenedVectorFrameChildren(
|
|||
placement: VectorFramePlacement
|
||||
): void {
|
||||
let run: { path: VectorizedPath; index: number }[] = []
|
||||
let runClipNetworks: VectorNetwork[] | undefined
|
||||
const flush = () => {
|
||||
if (run.length === 0) return
|
||||
const targetFrameId = runClipNetworks
|
||||
? createClipFrames(graph, frameId, runClipNetworks, placement, run[0].index)
|
||||
: frameId
|
||||
if (run.length > 1) {
|
||||
createFlattenedVectorChild(
|
||||
graph,
|
||||
frameId,
|
||||
targetFrameId,
|
||||
run.map(({ path }) => path),
|
||||
placement,
|
||||
run[0].index
|
||||
)
|
||||
} else if (run[0]) {
|
||||
createVectorChild(graph, frameId, run[0].path, placement, run[0].index)
|
||||
createVectorChild(graph, targetFrameId, run[0].path, placement, run[0].index)
|
||||
}
|
||||
run = []
|
||||
runClipNetworks = undefined
|
||||
}
|
||||
|
||||
for (const [index, path] of vectorized.paths.entries()) {
|
||||
const clipNetworks = path.clipNetworks
|
||||
if (isFlattenableVectorPath(path)) {
|
||||
if (run.length > 0 && runClipNetworks !== clipNetworks) flush()
|
||||
runClipNetworks = clipNetworks
|
||||
run.push({ path, index })
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
createVectorChild(graph, frameId, path, placement, index)
|
||||
if (clipNetworks) {
|
||||
const targetFrameId = createClipFrames(graph, frameId, clipNetworks, placement, index)
|
||||
createVectorChild(graph, targetFrameId, path, placement, index)
|
||||
} else {
|
||||
createVectorChild(graph, frameId, path, placement, index)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
* reflect the input pixel size. Scale path data from the SVG coordinate space
|
||||
* (viewBox, else width/height) into the target node bounds before parsing.
|
||||
*/
|
||||
import svgpath from 'svgpath'
|
||||
|
||||
import type { Fill, Stroke, VectorNetwork, WindingRule } from '@open-pencil/scene-graph'
|
||||
import { mergeVectorNetworks } from '@open-pencil/scene-graph'
|
||||
import { computeBounds } from '@open-pencil/scene-graph/geometry'
|
||||
import { parseSVGPath } from '@open-pencil/scene-graph/parse-path'
|
||||
import type { Rect, Size } from '@open-pencil/scene-graph/primitives'
|
||||
|
|
@ -59,6 +62,7 @@ export interface VectorizedPath {
|
|||
vectorNetwork: VectorNetwork
|
||||
fills: Fill[]
|
||||
strokes: Stroke[]
|
||||
clipNetworks?: VectorNetwork[]
|
||||
}
|
||||
|
||||
export interface SVGVectorizeResult {
|
||||
|
|
@ -89,12 +93,14 @@ export function svgToVectorPaths(
|
|||
const strokeScale = Math.min(viewport.scaleX, viewport.scaleY)
|
||||
|
||||
const vectorized: VectorizedPath[] = []
|
||||
const clipCache = new WeakMap<NonNullable<IconPathInfo['clipPaths']>, VectorNetwork[]>()
|
||||
for (const path of paths) {
|
||||
const fillRule: WindingRule = path.fillRule
|
||||
const transform = path.transform ?? null
|
||||
const pathData = applySVGTransformToPath(path.d, transform)
|
||||
const scaledD = mapSVGPathToViewport(pathData, viewport)
|
||||
const network = parseSVGPath(scaledD, fillRule)
|
||||
const pathBounds = computeAccurateBounds(network)
|
||||
const gradientFill =
|
||||
gradients.size > 0
|
||||
? resolveGradientFill(
|
||||
|
|
@ -105,10 +111,36 @@ export function svgToVectorPaths(
|
|||
computeAccurateBounds(network)
|
||||
)
|
||||
: null
|
||||
let clipNetworks: VectorNetwork[] | undefined
|
||||
if (path.clipPaths) {
|
||||
const hasObjectBoundingBoxClip = path.clipPaths.some(
|
||||
({ units }) => units === 'objectBoundingBox'
|
||||
)
|
||||
clipNetworks = hasObjectBoundingBoxClip ? undefined : clipCache.get(path.clipPaths)
|
||||
if (!clipNetworks) {
|
||||
clipNetworks = path.clipPaths.map((clipRegion) =>
|
||||
mergeVectorNetworks(
|
||||
clipRegion.paths.map((clipPath) => {
|
||||
let clipData = applySVGTransformToPath(clipPath.d, clipPath.transform ?? null)
|
||||
if (clipRegion.units === 'objectBoundingBox') {
|
||||
clipData = svgpath(clipData)
|
||||
.scale(pathBounds.width, pathBounds.height)
|
||||
.translate(pathBounds.x, pathBounds.y)
|
||||
.toString()
|
||||
return parseSVGPath(clipData, clipPath.fillRule)
|
||||
}
|
||||
return parseSVGPath(mapSVGPathToViewport(clipData, viewport), clipPath.fillRule)
|
||||
})
|
||||
)
|
||||
)
|
||||
if (!hasObjectBoundingBoxClip) clipCache.set(path.clipPaths, clipNetworks)
|
||||
}
|
||||
}
|
||||
vectorized.push({
|
||||
vectorNetwork: network,
|
||||
fills: gradientFill ? [gradientFill] : resolveFill(path, defaultColor),
|
||||
strokes: resolveStrokes(path, defaultColor, strokeScale)
|
||||
strokes: resolveStrokes(path, defaultColor, strokeScale),
|
||||
clipNetworks
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,11 +65,9 @@ export function createDocumentRecovery({
|
|||
if (requestedVersion === protectedVersion) return
|
||||
if (!writing) {
|
||||
const generation = lifecycleGeneration
|
||||
writing = runWrites(generation)
|
||||
.catch((error) => console.warn('[Recovery] Snapshot failed:', error))
|
||||
.finally(() => {
|
||||
writing = null
|
||||
})
|
||||
writing = runWrites(generation).finally(() => {
|
||||
writing = null
|
||||
})
|
||||
}
|
||||
await writing
|
||||
}
|
||||
|
|
@ -77,7 +75,7 @@ export function createDocumentRecovery({
|
|||
const stop: WatchHandle = watchDebounced(
|
||||
() => state.sceneVersion,
|
||||
() => {
|
||||
void persistNow()
|
||||
void persistNow().catch((error) => console.warn('[Recovery] Snapshot failed:', error))
|
||||
},
|
||||
{ debounce: 3000, maxWait: 10000 }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ function generateTabId(): string {
|
|||
}
|
||||
|
||||
const tabsRef = shallowRef<Tab[]>([])
|
||||
const pendingRecoveryDeletions = new Set<Promise<void>>()
|
||||
const activeTabId = shallowRef('')
|
||||
|
||||
export const activeTab = computed(() => tabsRef.value.find((t) => t.id === activeTabId.value))
|
||||
|
|
@ -98,13 +97,7 @@ export async function closeTab(tabId: string): Promise<void> {
|
|||
|
||||
const closingTab = tabsRef.value[idx]
|
||||
const wasActive = activeTabId.value === tabId
|
||||
const deletion = closingTab.store.discardRecovery()
|
||||
pendingRecoveryDeletions.add(deletion)
|
||||
try {
|
||||
await deletion
|
||||
} finally {
|
||||
pendingRecoveryDeletions.delete(deletion)
|
||||
}
|
||||
await closingTab.store.persistRecoveryNow()
|
||||
closingTab.store.dispose()
|
||||
tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId)
|
||||
|
||||
|
|
@ -304,10 +297,7 @@ export async function restoreRecoverySnapshot(id: string): Promise<void> {
|
|||
}
|
||||
|
||||
export async function prepareForReload(): Promise<void> {
|
||||
await Promise.all([
|
||||
...pendingRecoveryDeletions,
|
||||
...tabsRef.value.map((tab) => tab.store.persistRecoveryNow())
|
||||
])
|
||||
await Promise.all(tabsRef.value.map((tab) => tab.store.persistRecoveryNow()))
|
||||
}
|
||||
|
||||
export function tabCount(): number {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import Tip from '@/components/ui/Tip.vue'
|
|||
|
||||
import type { JSXFormat } from '@open-pencil/core/design-jsx'
|
||||
|
||||
const { active = true } = defineProps<{ active?: boolean }>()
|
||||
const store = useEditorStore()
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
const { dialogs } = useI18n()
|
||||
|
|
@ -29,6 +30,7 @@ function toggleFormat() {
|
|||
}
|
||||
|
||||
const jsxCode = useSceneComputed(() => {
|
||||
if (!active) return ''
|
||||
void store.state.sceneVersion
|
||||
const ids = [...store.state.selectedIds]
|
||||
if (ids.length === 0) return ''
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ const drawerTransition = {
|
|||
|
||||
<TabsContent value="code" class="mt-0 h-full data-[state=inactive]:hidden">
|
||||
<div data-test-id="mobile-drawer-code" class="flex h-full flex-col">
|
||||
<CodePanel />
|
||||
<CodePanel :active="isOpen && getDrawerTab() === 'code'" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ const { panels } = useI18n()
|
|||
:force-mount="true"
|
||||
:hidden="activeTab !== 'code'"
|
||||
>
|
||||
<CodePanel />
|
||||
<CodePanel :active="activeTab === 'code'" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
|
|
|
|||
|
|
@ -67,8 +67,9 @@ test('autosave triggers after scene changes with a file handle', async () => {
|
|||
expect(writeHappened).toBe(true)
|
||||
})
|
||||
|
||||
test('no autosave without file handle', async ({ browser }) => {
|
||||
test('no autosave without file handle', async ({ browser, baseURL }) => {
|
||||
const context = await browser.newContext({
|
||||
baseURL,
|
||||
viewport: { width: 1280, height: 800 },
|
||||
deviceScaleFactor: 2
|
||||
})
|
||||
|
|
|
|||
33
tests/e2e/code/mobile-panel.spec.ts
Normal file
33
tests/e2e/code/mobile-panel.spec.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { test, expect, useEditorSetup } from '#tests/e2e/fixtures'
|
||||
|
||||
const editor = useEditorSetup()
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
test('closed mobile Code drawer defers JSX until reopened', async () => {
|
||||
await editor.page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const frameId = store.createShape('FRAME', 0, 0, 100, 100)
|
||||
store.select([frameId])
|
||||
})
|
||||
|
||||
await editor.page.getByTestId('mobile-ribbon-code').click()
|
||||
await expect(editor.page.getByTestId('code-panel')).toBeVisible()
|
||||
|
||||
await editor.page.getByTestId('mobile-ribbon-code').click()
|
||||
await expect
|
||||
.poll(() => editor.page.evaluate(() => window.openPencil?.getStore?.().state.mobileDrawerSnap))
|
||||
.toBe('closed')
|
||||
|
||||
await editor.page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
store.clearSelection()
|
||||
const rectangleId = store.createShape('RECTANGLE', 120, 0, 100, 100)
|
||||
store.select([rectangleId])
|
||||
})
|
||||
|
||||
await editor.page.getByTestId('mobile-ribbon-code').click()
|
||||
await expect(editor.page.getByTestId('code-panel')).toContainText('Rectangle')
|
||||
})
|
||||
|
|
@ -26,6 +26,37 @@ function copyButton() {
|
|||
return editor.page.getByTestId('code-panel-copy')
|
||||
}
|
||||
|
||||
test('inactive Code tab skips JSX generation for large selections', async () => {
|
||||
const selectionDuration = await editor.page.evaluate(async () => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const pageId = store.state.currentPageId
|
||||
const ids: string[] = []
|
||||
for (let frameIndex = 0; frameIndex < 50; frameIndex++) {
|
||||
const frame = store.graph.createNode('FRAME', pageId, { name: `Frame ${frameIndex}` })
|
||||
ids.push(frame.id)
|
||||
for (let childIndex = 0; childIndex < 100; childIndex++) {
|
||||
store.graph.createNode('RECTANGLE', frame.id, { name: `Child ${childIndex}` })
|
||||
}
|
||||
}
|
||||
const startedAt = performance.now()
|
||||
store.select(ids)
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
return performance.now() - startedAt
|
||||
})
|
||||
|
||||
expect(selectionDuration).toBeLessThan(1000)
|
||||
await expect(designTab()).toHaveAttribute('data-state', 'active')
|
||||
|
||||
await codeTab().click()
|
||||
await expect(codePanel()).toContainText('Frame')
|
||||
|
||||
await editor.page.evaluate(() => window.openPencil?.getStore?.().clearSelection())
|
||||
await designTab().click()
|
||||
})
|
||||
|
||||
test('Code tab shows empty state with no selection', async () => {
|
||||
await codeTab().click()
|
||||
await expect(codePanelEmpty()).toBeVisible()
|
||||
|
|
|
|||
47
tests/e2e/recovery.spec.ts
Normal file
47
tests/e2e/recovery.spec.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { expect, test } from '#tests/e2e/fixtures'
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
|
||||
test('keeps an unsaved document recoverable after its tab closes', async ({ browser, baseURL }) => {
|
||||
const context = await browser.newContext({ baseURL })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/')
|
||||
const canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const id = store.createShape('RECTANGLE', 120, 120, 240, 140)
|
||||
await store.persistRecoveryNow()
|
||||
store.updateNode(id, { name: 'Retained recovery rectangle' })
|
||||
})
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+t')
|
||||
await expect(page.getByRole('button', { name: 'New tab' })).toBeVisible()
|
||||
await page.getByTestId('tabbar-tab').first().getByTestId('tabbar-close').click()
|
||||
await expect(page.getByRole('button', { name: 'New tab' })).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(async () => {
|
||||
const request = indexedDB.open('open-pencil-recovery')
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
const transaction = database.transaction('meta')
|
||||
const countRequest = transaction.objectStore('meta').count()
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
countRequest.onsuccess = () => resolve(countRequest.result)
|
||||
countRequest.onerror = () => reject(countRequest.error)
|
||||
})
|
||||
})
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
await page.reload()
|
||||
await expect(page.getByRole('alertdialog', { name: 'Recover unsaved work' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Restore' }).click()
|
||||
await expect(page.getByText('Retained recovery rectangle')).toBeVisible()
|
||||
|
||||
await context.close()
|
||||
})
|
||||
|
|
@ -86,6 +86,32 @@ describe('document recovery controller', () => {
|
|||
recovery.disposeRecovery()
|
||||
})
|
||||
|
||||
test('propagates persistence failures to close and reload callers', async () => {
|
||||
const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Draft' })
|
||||
const store = createMemoryRecoveryStore()
|
||||
const memoryWrite = store.write.bind(store)
|
||||
let writeAttempts = 0
|
||||
store.write = async (input) => {
|
||||
writeAttempts++
|
||||
if (writeAttempts === 1) throw new Error('recovery storage unavailable')
|
||||
return memoryWrite(input)
|
||||
}
|
||||
const recovery = createDocumentRecovery({
|
||||
state,
|
||||
store,
|
||||
recoveryId: 'recovery-1',
|
||||
hasWritableSource: () => false,
|
||||
buildFigFile: () => new Uint8Array([1])
|
||||
})
|
||||
state.sceneVersion = 1
|
||||
|
||||
await expect(recovery.persistNow()).rejects.toThrow('recovery storage unavailable')
|
||||
await recovery.persistNow()
|
||||
expect(writeAttempts).toBe(2)
|
||||
expect((await store.read('recovery-1'))?.sceneVersion).toBe(1)
|
||||
recovery.disposeRecovery()
|
||||
})
|
||||
|
||||
test('successful save removes recovery data', async () => {
|
||||
const { state, store, recovery } = setup()
|
||||
state.sceneVersion = 1
|
||||
|
|
|
|||
|
|
@ -263,6 +263,99 @@ describe('import_svg', () => {
|
|||
expect(path.fills[0].color.b).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
test('imports clip paths as masks for clipped paint runs', async () => {
|
||||
const result = (await importSVG.execute(figma, {
|
||||
svg: `<svg viewBox="0 0 100 100">
|
||||
<defs><path id="mark" d="M10 10H90V90H10Z"/></defs>
|
||||
<g clip-path="url(#clip)">
|
||||
<defs><clipPath id="clip"><use href="#mark"/></clipPath></defs>
|
||||
<rect width="50" height="100" fill="#ff0000"/>
|
||||
<rect x="50" width="50" height="100" fill="#0000ff"/>
|
||||
</g>
|
||||
<circle cx="50" cy="50" r="10" fill="#ffffff"/>
|
||||
</svg>`
|
||||
})) as { id: string }
|
||||
|
||||
const children = graph.getChildren(result.id)
|
||||
expect(children).toHaveLength(2)
|
||||
const clippedGroup = expectDefined(children[0])
|
||||
expect(clippedGroup.type).toBe('FRAME')
|
||||
const clippedChildren = graph.getChildren(clippedGroup.id)
|
||||
expect(clippedChildren).toHaveLength(2)
|
||||
expect(clippedChildren[0].isMask).toBe(true)
|
||||
expect(clippedChildren[0].maskType).toBe('VECTOR')
|
||||
expect(expectDefined(clippedChildren[0].vectorNetwork).regions).toHaveLength(1)
|
||||
expect(clippedChildren[1].fillGeometry).toHaveLength(2)
|
||||
expect(children[1].isMask).toBe(false)
|
||||
})
|
||||
|
||||
test('preserves inherited clips when expanding use elements', async () => {
|
||||
const result = (await importSVG.execute(figma, {
|
||||
svg: `<svg viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<path id="tile" d="M0 0H100V100H0Z"/>
|
||||
<clipPath id="clip"><rect x="20" y="20" width="60" height="60"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip)"><use href="#tile" fill="#ff0000"/></g>
|
||||
</svg>`
|
||||
})) as { id: string }
|
||||
|
||||
const clipFrame = expectDefined(graph.getChildren(result.id)[0])
|
||||
const clippedChildren = graph.getChildren(clipFrame.id)
|
||||
expect(clippedChildren).toHaveLength(2)
|
||||
expect(clippedChildren[0].isMask).toBe(true)
|
||||
expect(clippedChildren[1].fills[0].color.r).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
test('applies nested clip paths from outermost to innermost', async () => {
|
||||
const result = (await importSVG.execute(figma, {
|
||||
svg: `<svg viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<clipPath id="outer"><rect x="10" y="10" width="80" height="80"/></clipPath>
|
||||
<clipPath id="inner"><circle cx="50" cy="50" r="25"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#outer)">
|
||||
<rect width="100" height="100" fill="#ff0000" clip-path="url(#inner)"/>
|
||||
</g>
|
||||
</svg>`
|
||||
})) as { id: string }
|
||||
|
||||
const outerFrame = expectDefined(graph.getChildren(result.id)[0])
|
||||
const outerChildren = graph.getChildren(outerFrame.id)
|
||||
expect(outerChildren[0].isMask).toBe(true)
|
||||
const innerFrame = expectDefined(outerChildren[1])
|
||||
expect(innerFrame.type).toBe('FRAME')
|
||||
const innerChildren = graph.getChildren(innerFrame.id)
|
||||
expect(innerChildren[0].isMask).toBe(true)
|
||||
expect(innerChildren[1].type).toBe('VECTOR')
|
||||
})
|
||||
|
||||
test('maps objectBoundingBox clip paths to each painted path bounds', async () => {
|
||||
const result = (await importSVG.execute(figma, {
|
||||
svg: `<svg viewBox="0 0 200 100">
|
||||
<defs>
|
||||
<clipPath id="half" clipPathUnits="objectBoundingBox">
|
||||
<rect width="0.5" height="1"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect x="20" y="10" width="60" height="80" fill="#ff0000" clip-path="url(#half)"/>
|
||||
<rect x="120" y="20" width="40" height="60" fill="#0000ff" clip-path="url(#half)"/>
|
||||
</svg>`
|
||||
})) as { id: string }
|
||||
|
||||
const [leftFrame, rightFrame] = graph.getChildren(result.id)
|
||||
const leftMask = expectDefined(graph.getChildren(expectDefined(leftFrame).id)[0])
|
||||
const rightMask = expectDefined(graph.getChildren(expectDefined(rightFrame).id)[0])
|
||||
expect(leftMask.x).toBeCloseTo(20)
|
||||
expect(leftMask.y).toBeCloseTo(10)
|
||||
expect(leftMask.width).toBeCloseTo(30)
|
||||
expect(leftMask.height).toBeCloseTo(80)
|
||||
expect(rightMask.x).toBeCloseTo(120)
|
||||
expect(rightMask.y).toBeCloseTo(20)
|
||||
expect(rightMask.width).toBeCloseTo(20)
|
||||
expect(rightMask.height).toBeCloseTo(60)
|
||||
})
|
||||
|
||||
test('imports gradient fills through the shared SVG pipeline', async () => {
|
||||
const result = (await importSVG.execute(figma, {
|
||||
svg: `<svg viewBox="0 0 10 10"><defs><linearGradient id="g"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="10" height="10" fill="url(#g)"/></svg>`
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ mockIPC((cmd, args) => {
|
|||
if (payload.thumbnailPng.length === 0) throw new Error('thumbnailPng is empty')
|
||||
if (payload.images.length !== 0) throw new Error('images should be empty')
|
||||
JSON.parse(payload.metaJson)
|
||||
return [7, 8, 9]
|
||||
return new Uint8Array([7, 8, 9]).buffer
|
||||
})
|
||||
|
||||
const [{ exportFigFile }, { SceneGraph }] = await Promise.all([
|
||||
|
|
|
|||
Loading…
Reference in a new issue