Add multi-selection clipboard commands
This commit is contained in:
parent
987e289f89
commit
edca82cd5d
10 changed files with 839 additions and 36 deletions
96
frontend/test/arthur/domain/clipboard_test.cljs
Normal file
96
frontend/test/arthur/domain/clipboard_test.cljs
Normal 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)))))
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue