Add multi-selection clipboard commands

This commit is contained in:
Your Name 2026-10-02 16:13:03 -04:00
parent 987e289f89
commit edca82cd5d
10 changed files with 839 additions and 36 deletions

View file

@ -0,0 +1,96 @@
(ns arthur.domain.clipboard-test
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.clip :as clip]
[arthur.domain.clipboard :as clipboard]
[arthur.domain.node :as node]
[arthur.domain.sequence-test :as fixture]))
(defn- ids []
(let [n (atom 0)]
#(keyword (str "copy-" (swap! n inc)))))
(defn- address [sid id path] [:node sid id path])
(deftest canonical-selection-is-an-occurrence-forest
(let [doc (-> (fixture/document)
(assoc-in [:symbols :drawing-a :nodes :g]
{:id :g :kind :group :z "g"})
(assoc-in [:symbols :drawing-a :nodes :child]
{:id :child :kind :rect :parent :g :z "h"}))
selections [(address :drawing-a :child [:a :g :child])
(address :drawing-a :g [:a :g])]
payload (get-in (clipboard/snapshot doc selections) [:clipboard :items])]
(is (= 1 (count payload)))
(is (= :g (:root (first payload))))
(is (= #{:g :child} (set (keys (:nodes (first payload))))))))
(deftest copy-paste-remaps-the-subtree-and-keeps-shared-content
(let [doc (fixture/document)
payload (:clipboard (clipboard/snapshot doc [(address :main :a [:a])]))
r (clipboard/paste (update-in doc [:symbols :main] dissoc :display)
payload :main 20 {:fresh-id (ids)})
made (:clip r)
id (first (:roots r))
n (get-in made [:symbols :main :nodes id])]
(is (nil? (:refused r)))
(is (= :drawing-a (node/source n)) "normal paste keeps symbol identity")
(is (= [20 24] (node/placed-span n)))
(is (= 12 (get-in made [:symbols :main :frames]))
"an ordinary symbol may contain cropped content beyond its window")
(is (empty? (clip/problems made)))))
(deftest lane-paste-claims-time-as-one-command
(let [doc (fixture/document)
payload (:clipboard (clipboard/snapshot doc [(address :main :a [:a])]))
r (clipboard/paste doc payload :main 4 {:fresh-id (ids)})
made (:clip r)
id (first (:roots r))]
(is (= :drawing-a (node/source (get-in made [:symbols :main :nodes id]))))
(is (nil? (get-in made [:symbols :main :nodes :b]))
"the pasted interval replaces the cel it covers")
(is (= [4 8] (node/placed-span (get-in made [:symbols :main :nodes id]))))
(is (empty? (clip/problems made)))))
(deftest lane-duplicate-is-the-forward-repeat-and-ripples-later-cels
(let [doc (fixture/document)
payload (:clipboard
(clipboard/snapshot doc [(address :main :a [:a])
(address :main :b [:b])]))
r (clipboard/duplicate doc payload {:fresh-id (ids)})
made (:clip r)
[a2 b2] (map :id (:roots r))]
(is (= [8 12] (node/placed-span (get-in made [:symbols :main :nodes a2]))))
(is (= [12 16] (node/placed-span (get-in made [:symbols :main :nodes b2]))))
(is (= [16 20] (node/placed-span (get-in made [:symbols :main :nodes :insert]))))
(is (= 20 (get-in made [:symbols :main :frames])))
(is (empty? (clip/problems made)))))
(deftest duplicate-unique-copies-one-complete-shared-symbol-graph
(let [doc (-> (fixture/document)
(assoc-in [:symbols :drawing-a :nodes :part]
{:id :part :kind :instance :z "b" :span [0 1]
:time {:at 0 :rate 1} :source {:symbol :wave}
:playback {:in 0 :speed 0 :end :stop}})
(update-in [:symbols :main] dissoc :display))
payload (:clipboard (clipboard/snapshot doc [(address :main :a [:a])]))
shared (:clip (clipboard/duplicate doc payload {:fresh-id (ids)}))
unique-r (clipboard/duplicate doc payload {:fresh-id (ids) :unique? true})
unique (:clip unique-r)
shared-id (:id (first (:roots (clipboard/duplicate doc payload {:fresh-id (ids)}))))
unique-id (:id (first (:roots unique-r)))
unique-source (node/source (get-in unique [:symbols :main :nodes unique-id]))]
(is (= :drawing-a (node/source (get-in shared [:symbols :main :nodes shared-id]))))
(is (not= :drawing-a unique-source))
(is (not= :wave (node/source (get-in unique [:symbols unique-source :nodes :part]))))
(is (empty? (clip/problems unique)))))
(deftest cut-keeps-the-snapshot-and-deletes-a-whole-subtree
(let [doc (-> (fixture/document)
(assoc-in [:symbols :main :nodes :child]
{:id :child :kind :rect :parent :plate :z "b"}))
payload (:clipboard (clipboard/snapshot doc [(address :main :plate [:plate])]))
made (:clip (clipboard/cut doc payload))]
(is (= #{:plate :child} (set (keys (:nodes (first (:items payload)))))))
(is (nil? (get-in made [:symbols :main :nodes :plate])))
(is (nil? (get-in made [:symbols :main :nodes :child])))
(is (empty? (clip/problems made)))))

View file

@ -104,6 +104,22 @@
(is (= 12 (ui/selection-frame doc nil :main
[:node :main :a [:a]] 12)))))
(deftest paste-targeting-continues-an-instance-clock-past-its-window
(let [doc {:fps 24 :width 20 :height 20
:symbols
{:main {:id :main :frames 40
:nodes {:drawing {:id :drawing :kind :instance :z "a"
:span [0 10] :time {:at 0 :rate 1}
:source {:symbol :inside}
:playback {:in 0 :speed 1 :end :stop}}}}
:inside {:id :inside :frames 10 :nodes {}}}}
db {:ui {:open :main
:target {:sid :main :id :drawing :path [:drawing]}}
:playback {:frame 20}}]
(is (= {:sid :inside :path [:drawing] :frame 20}
(ui/paste-destination db doc nil))
"pasting outside a target symbol's window authors cropped content")))
(deftest polygon-landing-obeys-the-destination-symbol-mode
(let [lane (fixture/document)
ordinary (update-in lane [:symbols :main] dissoc :display)

View file

@ -96,6 +96,14 @@ try {
};
const shot = async () => JSON.parse(JSON.stringify(await evaluate('laneSnapshot()'),
(_key, value) => value?.uuid ?? value));
const shortcut = async (key, code, modifiers = 2) => {
const windowsVirtualKeyCode = key.toUpperCase().charCodeAt(0);
await send('Input.dispatchKeyEvent', {type: 'rawKeyDown', key, code, modifiers,
windowsVirtualKeyCode});
await send('Input.dispatchKeyEvent', {type: 'keyUp', key, code, modifiers: 0,
windowsVirtualKeyCode});
await sleep(180);
};
const mainInstances = s => Object.values(s.clip.symbols.main.nodes)
.filter(n => n.kind === 'instance');
const laneSymbols = s => Object.values(s.clip.symbols).filter(sym => sym.display === 'lane');
@ -109,7 +117,7 @@ try {
assert.equal(placed.length, 1, 'new symbol places one instance');
assert.equal(s.clip.symbols[placed[0].source.symbol].display, undefined,
'new symbol remains ordinary');
assert.equal(await evaluate('document.querySelectorAll(".tl-track").length'), 1,
assert.equal(await evaluate('document.querySelectorAll(".tl-track:not(.palette)").length'), 1,
'an ordinary symbol is an ordinary timeline row');
await clickNew('lane');
@ -151,16 +159,17 @@ try {
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: drop.sx, y: drop.sy});
await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: drop.sx, y: drop.sy,
button: 'left', buttons: 1, clickCount: 1});
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: drop.sx + 12, y: drop.sy,
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: drop.sx + 6, y: drop.sy,
button: 'left', buttons: 1});
await sleep(100);
await sleep(60);
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: drop.sx + 18, y: drop.sy,
button: 'left', buttons: 1});
await sleep(140);
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: drop.tx, y: drop.ty,
button: 'left', buttons: 1});
await sleep(120);
await sleep(300);
assert.equal(await evaluate('document.querySelectorAll(".tl-label.ghost").length'), 0,
'pool drop does not preview an invented lane');
assert.equal(await evaluate('document.querySelectorAll(".tl-cel.ghost").length'), 1,
'pool drop previews inside the existing lane');
await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: drop.tx, y: drop.ty,
button: 'left', buttons: 0, clickCount: 1});
await sleep(300);
@ -196,8 +205,51 @@ try {
s = await shot();
assert.deepEqual(laneSymbols(s).map(x => Object.keys(x.nodes).length).sort(), [0, 1],
'a clip body moves from one explicit lane to the other');
// Start a clean local document for clipboard and timeline multi-selection.
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(
cljs.core.keyword('arthur.events.project/new'))) `);
await sleep(250);
await clickNew('inside');
const clipboardBaseline = (await shot()).clip;
await shortcut('d', 'KeyD');
assert.equal(mainInstances(await shot()).length, 2, 'duplicate copies the selected instance');
await shortcut('z', 'KeyZ');
assert.equal(mainInstances(await shot()).length, 1, 'one undo removes the whole duplicate');
await shortcut('z', 'KeyZ', 10);
assert.equal(mainInstances(await shot()).length, 2, 'one redo restores the duplicate');
assert(await evaluate(`(() => {
const rows = [...document.querySelectorAll('.tl-label')].filter(x => x.draggable);
if (rows.length < 2) return false;
rows[0].click();
rows[1].dispatchEvent(new MouseEvent('click', {bubbles: true, shiftKey: true}));
return true;
})()`), 'two timeline rows are available for multi-selection');
await sleep(200);
assert.equal(await evaluate('document.querySelectorAll(".tl-label.on").length'), 2,
'timeline Shift-click mirrors the two-member selection');
assert(await evaluate(`(() => {
const row = [...document.querySelectorAll('.tl-label')].find(x => x.draggable);
if (!row) return false; row.click(); return true;
})()`), 'a timeline row can select the redone instance');
await sleep(120);
await shortcut('c', 'KeyC');
await shortcut('x', 'KeyX');
assert.equal(mainInstances(await shot()).length, 1, 'cut removes the selection');
await shortcut('z', 'KeyZ');
assert.equal(mainInstances(await shot()).length, 2, 'one undo restores the cut');
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(
cljs.core.keyword('arthur.events.ui/aim'), null))`);
await sleep(80);
await shortcut('v', 'KeyV');
assert.equal(mainInstances(await shot()).length, 3, 'paste uses the copied snapshot');
await shortcut('z', 'KeyZ');
await shortcut('z', 'KeyZ');
assert.equal(mainInstances(await shot()).length, 1, 'clipboard edits undo independently');
assert.deepEqual((await shot()).clip, clipboardBaseline,
'clipboard exercises undo back to the byte-for-byte document');
assert.equal(errors.length, 0, JSON.stringify(errors));
console.log('PASS: symbols are ordinary; explicit lanes rename, accept drops, and exchange clips');
console.log('PASS: lanes, mirrored selection, and atomic clipboard commands');
} finally {
if (ws?.readyState === WebSocket.OPEN) ws.close();
chrome.kill('SIGTERM');