Fix pasteID int overflow and make error toasts copyable

- Use Int32Array instead of Uint32Array for Kiwi pasteID (signed int field)
- Error toasts: don't auto-dismiss, show copy button, text is selectable
- Add clipboard roundtrip tests (encode → decode → verify)
This commit is contained in:
Danila Poyarkov 2026-03-02 18:45:50 +03:00
parent af9b9fd0fa
commit ce55af6171
3 changed files with 142 additions and 8 deletions

View file

@ -420,7 +420,7 @@ export function buildFigmaClipboardHTML(nodes: SceneNode[], graph: SceneGraph):
type: 'NODE_CHANGES',
sessionID: 0,
ackID: 0,
pasteID: crypto.getRandomValues(new Uint32Array(1))[0],
pasteID: crypto.getRandomValues(new Int32Array(1))[0],
pasteFileKey: 'openpencil',
nodeChanges
}

View file

@ -1,15 +1,20 @@
<script setup lang="ts">
import { ToastProvider, ToastRoot, ToastDescription, ToastViewport } from 'reka-ui'
import { ToastProvider, ToastRoot, ToastDescription, ToastViewport, ToastClose } from 'reka-ui'
import { useClipboard } from '@vueuse/core'
import { toast } from '@/composables/use-toast'
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
</script>
<template>
<ToastProvider :duration="toast.TOAST_DURATION" swipe-direction="up">
<ToastProvider swipe-direction="up">
<ToastRoot
v-for="t in toast.toasts.value"
:key="t.id"
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1 data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[swipe=move]:translate-y-[var(--reka-toast-swipe-move-y)] data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform"
:duration="t.variant === 'error' ? 0 : toast.TOAST_DURATION"
class="flex max-w-sm items-start gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1 data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[swipe=move]:translate-y-[var(--reka-toast-swipe-move-y)] data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform"
:class="t.variant === 'error' ? 'bg-red-600' : 'bg-blue-600'"
@update:open="
(open) => {
@ -17,9 +22,24 @@ import { toast } from '@/composables/use-toast'
}
"
>
<icon-lucide-check v-if="t.variant === 'default'" class="size-3 shrink-0" />
<icon-lucide-alert-triangle v-else class="size-3 shrink-0" />
<ToastDescription>{{ t.message }}</ToastDescription>
<icon-lucide-check v-if="t.variant === 'default'" class="mt-0.5 size-3 shrink-0" />
<icon-lucide-alert-triangle v-else class="mt-0.5 size-3 shrink-0" />
<ToastDescription class="min-w-0 flex-1 select-text">{{ t.message }}</ToastDescription>
<button
v-if="t.variant === 'error'"
class="mt-0.5 shrink-0 cursor-pointer rounded p-0.5 opacity-70 hover:opacity-100"
:title="copied ? 'Copied!' : 'Copy error'"
@click="copy(t.message)"
>
<icon-lucide-check v-if="copied" class="size-3" />
<icon-lucide-copy v-else class="size-3" />
</button>
<ToastClose
v-if="t.variant === 'error'"
class="mt-0.5 shrink-0 cursor-pointer rounded p-0.5 opacity-70 hover:opacity-100"
>
<icon-lucide-x class="size-3" />
</ToastClose>
</ToastRoot>
<ToastViewport

View file

@ -1,10 +1,12 @@
import { describe, expect, it } from 'bun:test'
import { beforeAll, describe, expect, it } from 'bun:test'
import {
parseFigmaClipboard,
importClipboardNodes,
figmaNodesBounds,
buildFigmaClipboardHTML,
} from '../../packages/core/src/clipboard'
import { initCodec } from '../../packages/core/src/kiwi/codec'
import { SceneGraph, type SceneNode } from '../../packages/core/src/scene-graph'
function makeClipboardHtml(nodeChanges: unknown[], meta = { fileKey: 'test', pasteID: 1, dataType: 'scene' }) {
@ -334,3 +336,115 @@ describe('figmaNodesBounds', () => {
expect(figmaNodesBounds(nodes)).toBeNull()
})
})
describe('buildFigmaClipboardHTML', () => {
beforeAll(async () => {
await initCodec()
})
it('encodes a simple frame without throwing', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
name: 'Card',
x: 0, y: 0, width: 300, height: 200,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
})
const html = buildFigmaClipboardHTML([frame], graph)
expect(html).toContain('figmeta')
expect(html).toContain('figma')
})
it('encodes text nodes with style runs', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const text = graph.createNode('TEXT', page.id, {
name: 'Styled',
x: 0, y: 0, width: 200, height: 24,
text: 'Hello World',
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 16,
styleRuns: [
{ start: 0, length: 5, style: { fontWeight: 700 } },
{ start: 6, length: 5, style: { fontWeight: 400, italic: true } },
],
})
const html = buildFigmaClipboardHTML([text], graph)
expect(html).toContain('figmeta')
})
it('encodes auto-layout frames', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
name: 'Row',
x: 0, y: 0, width: 400, height: 100,
layoutMode: 'HORIZONTAL',
itemSpacing: 16,
paddingTop: 12, paddingRight: 12, paddingBottom: 12, paddingLeft: 12,
primaryAxisSizing: 'HUG',
counterAxisSizing: 'FIXED',
})
graph.createNode('RECTANGLE', frame.id, {
name: 'Child',
x: 0, y: 0, width: 50, height: 50,
})
const html = buildFigmaClipboardHTML([frame], graph)
expect(html).toContain('figmeta')
})
it('roundtrips: encode then decode back', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
name: 'Analytics Overview',
x: 0, y: 0, width: 300, height: 200,
layoutMode: 'VERTICAL',
itemSpacing: 8,
paddingTop: 20, paddingRight: 20, paddingBottom: 20, paddingLeft: 20,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
cornerRadius: 12,
})
graph.createNode('TEXT', frame.id, {
name: 'Title',
x: 0, y: 0, width: 260, height: 24,
text: 'Analytics Overview',
fontFamily: 'Inter',
fontWeight: 600,
fontSize: 18,
})
graph.createNode('TEXT', frame.id, {
name: 'Subtitle',
x: 0, y: 0, width: 260, height: 40,
text: 'Track your key metrics and performance indicators in real time.',
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 14,
})
const html = buildFigmaClipboardHTML([frame], graph)
expect(html).not.toBeNull()
const parsed = await parseFigmaClipboard(html!)
expect(parsed).not.toBeNull()
expect(parsed!.nodes.length).toBeGreaterThan(0)
const graph2 = new SceneGraph()
const page2 = graph2.getPages()[0]
const created = importClipboardNodes(parsed!.nodes, graph2, page2.id)
expect(created).toHaveLength(1)
const imported = graph2.getNode(created[0])!
expect(imported.name).toBe('Analytics Overview')
expect(imported.cornerRadius).toBe(12)
const children = graph2.getChildren(imported.id)
expect(children).toHaveLength(2)
expect(children[0].text).toBe('Analytics Overview')
expect(children[1].text).toContain('Track your key metrics')
})
})