Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 113 additions & 4 deletions packages/core/src/clipboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ export { deserializeClipboard, isCanvasHarnessClipboard, serializeSelection } fr
const MIME_NATIVE = 'application/x-canvas-harness+json'
const MIME_TEXT = 'text/plain'

// Per-store in-memory fallback clipboard. Used ONLY when a paste sees a
// completely empty DataTransfer (a WKWebView quirk where the system
// clipboard read yields nothing despite a prior in-app copy). Keyed by
// store so independent <Canvas> instances never leak into each other,
// and never consulted when the transfer holds real (external) content —
// otherwise a paste of outside data would be hijacked by stale nodes.
const memoryByStore = new WeakMap<CanvasStore, SerializedClipboard>()

const textFallback = (clip: SerializedClipboard): string =>
clip.nodes
.map(n => n.content ?? '')
.filter(s => s.length > 0)
.join('\n')

/**
* Copies the current selection to the system clipboard. Writes both a
* native MIME (`application/x-canvas-harness+json`) and a `text/plain`
Expand All @@ -31,6 +45,7 @@ const MIME_TEXT = 'text/plain'
*/
export const copy = async (store: CanvasStore): Promise<SerializedClipboard> => {
const clip = serializeSelection(store)
memoryByStore.set(store, clip)
await writeClipboard(clip)
return clip
}
Expand Down Expand Up @@ -81,6 +96,12 @@ export const paste = async (
payload?: SerializedClipboard,
opts?: DeserializeOptions,
): Promise<(NodeId | EdgeId)[] | null> => {
// Explicit payload, else the system clipboard. Deliberately NO
// in-memory fallback here: readClipboard() returns null for both an
// empty/unavailable clipboard AND a non-canvas one, so falling back to
// memory would paste stale nodes over legitimate external content. The
// DOM paste-event path handles the WebKit empty-transfer case instead,
// via readClipboardFromDataTransfer (which can tell the two apart).
const clip = payload ?? (await readClipboard())
if (!clip) return null
// Cursor-as-default: when the caller didn't specify positioning,
Expand All @@ -98,13 +119,101 @@ export const paste = async (
return ids
}

/**
* Copy the current selection into a `DataTransfer` — the WebKit-safe
* path, called from the DOM `copy`/`cut` events (which run inside the
* user gesture and expose a synchronous `DataTransfer`, unlike the
* restricted async `navigator.clipboard`). Also stashes an in-memory
* copy so paste works even when the system clipboard drops our payload.
*
* `<Canvas>` wires this to Cmd/Ctrl+C. Call directly only for a custom
* copy handler on a `copy`/`cut` event.
*/
export const writeSelectionToDataTransfer = (
store: CanvasStore,
data: DataTransfer,
): SerializedClipboard => {
const clip = serializeSelection(store)
memoryByStore.set(store, clip)
const json = JSON.stringify(clip)
try {
// Custom MIME for engines that keep it (Chromium). WebKit drops it
// from a DataTransfer read, which is why the JSON also rides in
// text/plain below — the one type WebKit reliably round-trips.
data.setData(MIME_NATIVE, json)
} catch {
// Ignore — text/plain carries the payload.
}
// text/plain holds the JSON (not human text) so intra-app paste
// round-trips in WebKit via the synchronous paste event. Trade-off:
// pasting a selection into a plain-text field shows JSON.
data.setData(MIME_TEXT, json)
return clip
}

/**
* Cut into a `DataTransfer`: copy the selection then remove it, in one
* undoable batch. The DOM `cut` event path. Shares the write + removal
* contract with the rest of the module so the React layer doesn't
* re-implement it.
*/
export const cutSelectionToDataTransfer = (
store: CanvasStore,
data: DataTransfer,
): SerializedClipboard => {
const clip = writeSelectionToDataTransfer(store, data)
store.batch(() => {
for (const n of clip.nodes) store.removeNode(n.id)
for (const e of clip.edges) store.removeEdge(e.id)
})
return clip
}

/**
* Read a canvas-harness payload from a `DataTransfer` (the DOM `paste`
* event). Precedence: native MIME → JSON in text/plain → (only when the
* transfer is completely empty) the per-store in-memory fallback.
*
* Returns null when the transfer holds content that isn't ours (plain
* text, an image, foreign JSON) so an external paste is never hijacked
* by a previously-copied canvas selection. `store` scopes the fallback.
*/
export const readClipboardFromDataTransfer = (
store: CanvasStore,
data: DataTransfer,
): SerializedClipboard | null => {
const native = data.getData(MIME_NATIVE)
if (native) {
try {
const parsed = JSON.parse(native)
if (isCanvasHarnessClipboard(parsed)) return parsed
} catch {
// Fall through to text/plain.
}
}
const text = data.getData(MIME_TEXT)
if (text) {
// The transfer carries real content. Use it only if it's ours;
// otherwise it's an external paste — return null, don't fall back to
// the stale in-memory clipboard.
if (text.trim().startsWith('{')) {
try {
const parsed = JSON.parse(text)
if (isCanvasHarnessClipboard(parsed)) return parsed
} catch {
// Malformed JSON text — treat as external, fall through to null.
}
}
return null
}
// Empty transfer (the WKWebView quirk) → last in-app copy for this store.
return memoryByStore.get(store) ?? null
}

const writeClipboard = async (clip: SerializedClipboard): Promise<void> => {
if (typeof navigator === 'undefined' || !navigator.clipboard) return
const json = JSON.stringify(clip)
const text = clip.nodes
.map(n => n.content ?? '')
.filter(s => s.length > 0)
.join('\n')
const text = textFallback(clip)
// navigator.clipboard.write expects ClipboardItem; not all engines
// support arbitrary mime types. We dual-write best-effort.
type ClipboardItemCtor = new (data: Record<string, Blob>) => ClipboardItem
Expand Down
96 changes: 82 additions & 14 deletions packages/react/src/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import {
type EditorAdapterFactory,
type NodeId,
type Renderer,
copy,
createRenderer,
cut,
cutSelectionToDataTransfer,
hitTestAny,
paste,
readClipboardFromDataTransfer,
screenToWorld,
writeSelectionToDataTransfer,
} from '@canvas-harness/core'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { CanvasProvider, useCanvasStore } from './context'
Expand Down Expand Up @@ -459,24 +460,15 @@ function CanvasSurface({
}
}, [store, onCreateDrag])

// Cmd/Ctrl+C/X/V — copy/cut/paste. Skip when an input is focused so
// the editor's native text-clipboard isn't hijacked.
// Cmd/Ctrl+[ / ] — z-order. (Copy/cut/paste are handled via the DOM
// clipboard events below, not keydown.) Skip when an input is focused.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null
if (target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT')) return
const meta = e.metaKey || e.ctrlKey
if (!meta) return
if (e.key === 'c' || e.key === 'C') {
e.preventDefault()
void copy(store)
} else if (e.key === 'x' || e.key === 'X') {
e.preventDefault()
void cut(store)
} else if (e.key === 'v' || e.key === 'V') {
e.preventDefault()
void paste(store)
} else if (e.key === ']') {
if (e.key === ']') {
// Cmd+] = bring forward; Cmd+Shift+] = bring to front.
const selection = store.getSelection()
if (selection.length === 0) return
Expand All @@ -496,6 +488,75 @@ function CanvasSurface({
return () => window.removeEventListener('keydown', onKey)
}, [store])

// Copy / cut / paste via the DOM clipboard events (not keydown). These
// fire inside the user gesture and expose a synchronous DataTransfer,
// which WebKit (Safari / WKWebView) allows — unlike the async
// navigator.clipboard read it restricts, which is why keydown +
// navigator.clipboard silently no-op'd there.
//
// Listeners live on the HOST element, not window: the events only
// reach it when the canvas (or a child) holds focus, so an ordinary
// page-text copy elsewhere on the page is never hijacked. Skip when a
// text editor is focused so the native text clipboard still works.
useEffect(() => {
const el = wrapRef.current
if (!el) return
const isTextTarget = (t: EventTarget | null): boolean => {
const node = t as HTMLElement | null
return (
!!node &&
(node.tagName === 'TEXTAREA' || node.tagName === 'INPUT' || node.isContentEditable)
)
}
const onCopy = (e: ClipboardEvent) => {
if (isTextTarget(e.target) || !e.clipboardData || store.getSelection().length === 0) return
e.preventDefault()
writeSelectionToDataTransfer(store, e.clipboardData)
}
const onCut = (e: ClipboardEvent) => {
if (isTextTarget(e.target) || !e.clipboardData || store.getSelection().length === 0) return
e.preventDefault()
cutSelectionToDataTransfer(store, e.clipboardData)
}
const onPaste = (e: ClipboardEvent) => {
if (isTextTarget(e.target) || !e.clipboardData) return
const clip = readClipboardFromDataTransfer(store, e.clipboardData)
if (!clip) return
e.preventDefault()
void paste(store, clip)
}
el.addEventListener('copy', onCopy)
el.addEventListener('cut', onCut)
el.addEventListener('paste', onPaste)
return () => {
el.removeEventListener('copy', onCopy)
el.removeEventListener('cut', onCut)
el.removeEventListener('paste', onPaste)
}
}, [store])

// Focus the host on pointer interaction so the clipboard events above
// reliably fire in WebKit, which won't dispatch copy/cut/paste without
// a focused element. Skipped while editing, and when the pointer lands
// on an interactive control inside a custom DOM-overlay node — so we
// never steal focus from that node's own inputs/buttons.
useEffect(() => {
const el = wrapRef.current
if (!el) return
const onPointerDown = (e: PointerEvent) => {
if (store.getInteractionState().mode === 'editing') return
const t = e.target as HTMLElement | null
// Don't steal focus from a custom overlay node's own controls.
// `isContentEditable` covers every contenteditable variant (bare,
// "true", "plaintext-only") — matching the clipboard guard above.
if (t && t !== el && (t.isContentEditable || t.closest('input, textarea, select, button, a')))
return
el.focus({ preventScroll: true })
}
el.addEventListener('pointerdown', onPointerDown)
return () => el.removeEventListener('pointerdown', onPointerDown)
}, [store])

// Initial transform — subsequent updates are written directly to
// overlayRef.current.style by the camera-subscription effect above.
const initialCamera = store.getCamera()
Expand All @@ -505,9 +566,16 @@ function CanvasSurface({
<div
ref={wrapRef}
data-canvas-host=""
// Focusable so keyboard shortcuts + WebKit copy/cut/paste events
// target it; role="application" marks it an interactive surface;
// outline suppressed so the focus ring never paints on the canvas.
role="application"
// biome-ignore lint/a11y/noNoninteractiveTabindex: the canvas host is an interactive surface and must be focusable for keyboard + WebKit clipboard events
tabIndex={0}
style={{
position: 'absolute',
inset: 0,
outline: 'none',
background: '#f8fafc',
overflow: 'hidden',
cursor:
Expand Down
Loading
Loading