Add per-symbol onion skinning controls and cel ghosts
This commit is contained in:
parent
f2fc261221
commit
606055382b
8 changed files with 355 additions and 3 deletions
54
frontend/src/arthur/domain/onion.cljs
Normal file
54
frontend/src/arthur/domain/onion.cljs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
(ns arthur.domain.onion
|
||||
"Editor-only neighboring cel events, in output frames."
|
||||
(:require [arthur.domain.clip :as clip]
|
||||
[arthur.domain.nest :as nest]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
||||
(def defaults {:on? false :before 1 :after 1 :opacity 0.25 :scope :selected})
|
||||
|
||||
(defn neighbors [events f before after]
|
||||
(concat
|
||||
(map #(assoc % :direction :before)
|
||||
(take before (reverse (sort-by :start (filter #(<= (:end %) f) events)))))
|
||||
(map #(assoc % :direction :after)
|
||||
(take after (sort-by :start (filter #(> (:start %) f) events))))))
|
||||
|
||||
(defn samples [document store open selection f settings]
|
||||
(let [own (clip/shown-frame document open f)
|
||||
path (if (= :node (first selection)) (or (nth selection 3 nil) [(nth selection 2)]) [])
|
||||
locations (for [n (range (inc (count path)))
|
||||
:let [p (vec (take n path))
|
||||
sid (if (empty? p) open (:sid (nest/inside document store open p own)))]
|
||||
:when (symbol/lane? (clip/symbol document sid))]
|
||||
{:sid sid :path p})
|
||||
selected (last locations)
|
||||
;; Whole-symbol scope includes visible lane placements, recursively.
|
||||
walk (fn walk [sid p seen]
|
||||
(when-not (contains? seen sid)
|
||||
(let [sym (clip/symbol document sid)]
|
||||
(if (symbol/lane? sym)
|
||||
[{:sid sid :path p}]
|
||||
(mapcat (fn [[id n]]
|
||||
(when (node/source n)
|
||||
(let [q (conj p id)
|
||||
at (nest/inside document store open q own)]
|
||||
(when (:sid at) (walk (:sid at) q (conj seen sid))))))
|
||||
(:nodes sym))))))
|
||||
lanes (if (= :symbol (:scope settings)) (walk open [] #{}) (when selected [selected]))]
|
||||
(mapcat
|
||||
(fn [{:keys [sid path]}]
|
||||
(let [events (keep (fn [n]
|
||||
(let [id (:id n)
|
||||
q (conj path id)
|
||||
t (nest/own-time document store open q own)]
|
||||
(when (and (node/source n) (:span n) t (pos? (:rate t)))
|
||||
(let [[lo hi] (:span n)
|
||||
start (+ (:at t) (/ lo (:rate t)))
|
||||
end (+ (:at t) (/ hi (:rate t)))]
|
||||
{:start start :end end :path q
|
||||
:frame (clip/first-output-frame document open (js/Math.ceil start))}))))
|
||||
(symbol/children (:nodes (clip/symbol document sid))))]
|
||||
(filter #(< -1 (:frame %) (clip/output-frames document open))
|
||||
(neighbors events own (:before settings) (:after settings)))))
|
||||
lanes)))
|
||||
|
|
@ -74,3 +74,30 @@
|
|||
(raster/->rgba r palette-rgb 1 (.-data img))
|
||||
(.putImageData ctx img 0 0))
|
||||
(.toDataURL el "image/png")))
|
||||
|
||||
|
||||
(defonce ^:private onion-surface (atom nil))
|
||||
|
||||
(defn clear-overlay! [el]
|
||||
(when el
|
||||
(.clearRect (.getContext el "2d") 0 0 (.-width el) (.-height el))))
|
||||
|
||||
(defn tint-layer!
|
||||
"Composite a covered raster in one editor tint, keeping uncovered pixels clear."
|
||||
[el {:keys [w h cov]} [red green blue] opacity]
|
||||
(when el
|
||||
(let [scratch (or @onion-surface
|
||||
(reset! onion-surface (js/document.createElement "canvas")))
|
||||
_ (when (not= w (.-width scratch)) (set! (.-width scratch) w))
|
||||
_ (when (not= h (.-height scratch)) (set! (.-height scratch) h))
|
||||
ctx (.getContext scratch "2d")
|
||||
img (image-data-for ctx scratch w h)
|
||||
data (.-data img)]
|
||||
(dotimes [i (* w h)]
|
||||
(let [p (* i 4)]
|
||||
(aset data p red)
|
||||
(aset data (+ p 1) green)
|
||||
(aset data (+ p 2) blue)
|
||||
(aset data (+ p 3) (if (pos? (aget cov i)) (js/Math.round (* 255 opacity)) 0))))
|
||||
(.putImageData ctx img 0 0)
|
||||
(.drawImage (.getContext el "2d") scratch 0 0))))
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@
|
|||
drawers over the stage rather than columns beside it, which is the stylesheet's
|
||||
half of this (see `@media` in app.css), and a drawer that is shut should not be
|
||||
holding video thumbnails live."
|
||||
(:require [re-frame.core :as rf]))
|
||||
(:require [arthur.domain.onion :as onion]
|
||||
[re-frame.core :as rf]))
|
||||
|
||||
(def ^:private narrow?
|
||||
"A phone-shaped window, read ONCE at load. It decides what the layout opens
|
||||
|
|
@ -167,6 +168,38 @@
|
|||
(rf/reg-sub ::mat (fn [db _] (merge {:opacity 0.55} (get-in db [:ui :passepartout]))))
|
||||
(rf/reg-event-db ::mat (fn [db [_ k v]] (assoc-in db [:ui :passepartout k] v)))
|
||||
|
||||
(rf/reg-sub ::onion
|
||||
(fn [db _]
|
||||
(merge onion/defaults (get-in db [:ui :onion (:clip/current db) (get-in db [:ui :open])]))))
|
||||
(rf/reg-event-db ::onion
|
||||
(fn [db [_ k v]]
|
||||
(assoc-in db [:ui :onion (:clip/current db) (get-in db [:ui :open]) k] v)))
|
||||
|
||||
(defn onion-controls []
|
||||
(let [{:keys [on? before after opacity scope]} @(rf/subscribe [::onion])]
|
||||
[:div.group.onion-controls
|
||||
[:button {:class (when on? "on") :aria-pressed (boolean on?)
|
||||
:title "Onion skin — neighboring cels of the selected lane"
|
||||
:on-click #(rf/dispatch [::onion :on? (not on?)])} "onion"]
|
||||
[:details
|
||||
[:summary {:aria-label "Onion skin settings" :title "Onion skin settings"} "▾"]
|
||||
[:div.view-popout
|
||||
[:label "Scope"
|
||||
[:select {:value (name scope)
|
||||
:on-change #(rf/dispatch [::onion :scope (keyword (.. % -target -value))])}
|
||||
[:option {:value "selected"} "Selected lane"]
|
||||
[:option {:value "symbol"} "Whole symbol"]]]
|
||||
(for [[k label v] [[:before "Previous cels" before] [:after "Next cels" after]]]
|
||||
^{:key k} [:label label
|
||||
[:input {:type "number" :min 0 :max 5 :value v
|
||||
:on-change #(let [n (js/parseInt (.. % -target -value) 10)]
|
||||
(when-not (js/isNaN n)
|
||||
(rf/dispatch [::onion k (max 0 (min 5 n))])))}]])
|
||||
[:label "Opacity"
|
||||
[:input {:type "range" :min 0.05 :max 0.6 :step 0.05 :value opacity
|
||||
:on-change #(rf/dispatch [::onion :opacity (js/parseFloat (.. % -target -value))])}]]
|
||||
[:small "Previous: red · Next: blue. Select a lane or one of its cels. Hidden during playback."]]]]))
|
||||
|
||||
(defn fit-stage! []
|
||||
(when-let [area (.querySelector js/document ".stage-area")]
|
||||
(when-let [wrap (.querySelector area ".stage-wrap")]
|
||||
|
|
@ -179,6 +212,7 @@
|
|||
(defn stage-controls []
|
||||
(let [{:keys [opacity]} @(rf/subscribe [::mat])]
|
||||
[:<>
|
||||
[onion-controls]
|
||||
[:button {:on-click fit-stage! :title "Fit stage in view"} "fit"]
|
||||
[:details.passepartout-controls
|
||||
[:summary "passepartout"]
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.cut :as cut]
|
||||
[arthur.domain.outline :as outline]
|
||||
[arthur.domain.onion :as onion]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.pick :as pick]
|
||||
[arthur.domain.raster :as raster]
|
||||
|
|
@ -133,7 +134,12 @@
|
|||
:tracing t
|
||||
:frame @(rf/subscribe [::sub/frame])
|
||||
:playing? @(rf/subscribe [::sub/playing?])
|
||||
:pen pen})
|
||||
:pen pen
|
||||
:onion @(rf/subscribe [::layout/onion])
|
||||
:document @(rf/subscribe [::render/clip])
|
||||
:store @(rf/subscribe [::render/store])
|
||||
:open @(rf/subscribe [::render/open])
|
||||
:selection @(rf/subscribe [::ui-sub/selection])})
|
||||
;; A new resolver means a new scene or a new palette, and neither
|
||||
;; moves the playhead — so nothing else would ask for a redraw.
|
||||
;;
|
||||
|
|
@ -146,7 +152,10 @@
|
|||
;; Comparing the whole map is as cheap as picking fields out of it:
|
||||
;; the document and the store it carries are the same OBJECTS unless
|
||||
;; the resolver changed too, and that is tested first.
|
||||
(when-not (and (identical? was now) (= was-t t) (= pen (:pen before)) (= (:mat before) (:mat @snapshot)))
|
||||
(when-not (and (identical? was now) (= was-t t) (= pen (:pen before)) (= (:mat before) (:mat @snapshot))
|
||||
(= (:onion before) (:onion @snapshot))
|
||||
(= (:selection before) (:selection @snapshot))
|
||||
(= (:playing? before) (:playing? @snapshot)))
|
||||
(repaint!))))))
|
||||
|
||||
;; The brush or eraser stroke being painted: its mask, mutated in place as the
|
||||
|
|
@ -170,6 +179,30 @@
|
|||
;; — a blank canvas under a transport reading perfectly correct.
|
||||
(when el (repaint!)))
|
||||
|
||||
(defn set-onion-canvas! [el]
|
||||
(swap! state assoc :onion-canvas el)
|
||||
(when el (repaint!)))
|
||||
|
||||
(declare op-path)
|
||||
|
||||
(defn- paint-onions! [f]
|
||||
(let [{:keys [onion-canvas]} @state
|
||||
{:keys [onion playing? resolver document store open selection width height]} @snapshot]
|
||||
(canvas/clear-overlay! onion-canvas)
|
||||
(when (and onion-canvas (:on? onion) (not playing?))
|
||||
(doseq [{:keys [frame path direction]} (onion/samples document store open selection f onion)]
|
||||
;; Consume each result before asking the resolver for another frame:
|
||||
;; its geometry buffers are shared with the current picture.
|
||||
(let [ops (filter (fn [op]
|
||||
(and (not= :trace (:kind op))
|
||||
(= path (vec (take (count path) (op-path op))))))
|
||||
(resolver frame))
|
||||
layer (raster/layer width height)]
|
||||
(raster/draw-ops! layer ops)
|
||||
(canvas/tint-layer! onion-canvas layer
|
||||
(if (= :before direction) [235 75 75] [65 145 245])
|
||||
(:opacity onion)))))))
|
||||
|
||||
(defn- raster-for [w h]
|
||||
(let [{:keys [raster]} @state]
|
||||
(if (and raster (= w (:w raster)) (= h (:h raster)))
|
||||
|
|
@ -276,6 +309,7 @@
|
|||
;; THE STAGE IS THE CLIP'S, not a constant. Project dimensions are
|
||||
;; independent of the footage, so the size the frame is rasterised at comes
|
||||
;; out of the document like everything else.
|
||||
(paint-onions! f)
|
||||
(let [ras (raster-for width height)
|
||||
{traces true picture false} (group-by #(= :trace (:kind %)) (resolver f))
|
||||
;; A layer switched off, or all of them, is not on the stage at all:
|
||||
|
|
|
|||
|
|
@ -763,6 +763,10 @@
|
|||
:width w :height h
|
||||
:style {:width (str (* zoom w) "px")
|
||||
:height (str (* zoom h) "px")}}]
|
||||
[:canvas.onion-skin {:ref #(player/set-onion-canvas! %)
|
||||
:width w :height h
|
||||
:style {:width (str (* zoom w) "px")
|
||||
:height (str (* zoom h) "px")}}]
|
||||
[:canvas.tracing {:ref #(tracing/set-canvas! %)
|
||||
:width (* zoom w) :height (* zoom h)}]
|
||||
[:div.stage-shade {:style {:box-shadow (str "0 0 0 100vmax rgba(0,0,0," opacity ")")}}]
|
||||
|
|
|
|||
38
frontend/test/arthur/domain/onion_test.cljs
Normal file
38
frontend/test/arthur/domain/onion_test.cljs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
(ns arthur.domain.onion-test
|
||||
(:require [cljs.test :refer [deftest is]]
|
||||
[arthur.domain.onion :as onion]))
|
||||
|
||||
(def events [{:start 0 :end 12 :drawing :a}
|
||||
{:start 15 :end 17 :drawing :a}
|
||||
{:start 17 :end 19 :drawing :b}
|
||||
{:start 25 :end 30 :drawing :c}])
|
||||
|
||||
(deftest neighbors-count-events-and-skip-gaps
|
||||
(is (= [[0 :before] [17 :after]]
|
||||
(mapv (juxt :start :direction) (onion/neighbors events 16 1 1))))
|
||||
(is (= [0 15] (mapv :start (onion/neighbors events 13 1 1))))
|
||||
(is (= [15 0 25] (mapv :start (onion/neighbors events 18 2 1))))
|
||||
(is (empty? (onion/neighbors events 18 0 0))))
|
||||
|
||||
(def document
|
||||
{:fps 24 :stage [32 32]
|
||||
:symbols {:main {:frames 40 :fps 24 :nodes
|
||||
{:lane {:id :lane :kind :instance :source {:symbol :drawings}
|
||||
:span [0 40]}}}
|
||||
:drawings {:frames 40 :fps 24 :display :lane :nodes
|
||||
{:a {:id :a :kind :instance :source {:symbol :ink}
|
||||
:span [0 12]}
|
||||
:b {:id :b :kind :instance :source {:symbol :ink}
|
||||
:span [0 2] :time {:at 15 :rate 1}}
|
||||
:c {:id :c :kind :instance :source {:symbol :ink}
|
||||
:span [0 2] :time {:at 17 :rate 1}}}}
|
||||
:ink {:frames 1 :nodes {}}}})
|
||||
|
||||
(deftest selected-lane-and-symbol-scopes
|
||||
(let [settings (assoc onion/defaults :on? true)]
|
||||
(is (= [[0 [:lane :a]] [17 [:lane :c]]]
|
||||
(mapv (juxt :frame :path)
|
||||
(onion/samples document {} :main [:node :drawings :b [:lane :b]] 16 settings))))
|
||||
(is (empty? (onion/samples document {} :main nil 16 settings)))
|
||||
(is (= [0 17]
|
||||
(mapv :frame (onion/samples document {} :main nil 16 (assoc settings :scope :symbol)))))))
|
||||
148
frontend/test/browser/onion.mjs
Normal file
148
frontend/test/browser/onion.mjs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// Browser smoke test for onion controls and rendered ghost pixels.
|
||||
// Uses an in-memory fixture and performs no server-side writes.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const url = process.env.ARTHUR_URL ?? 'http://localhost:8778/';
|
||||
const profile = mkdtempSync(join(tmpdir(), 'arthur-onion-'));
|
||||
const port = 9337;
|
||||
const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [
|
||||
'--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
|
||||
'--no-default-browser-check', '--mute-audio', '--window-size=1440,1000',
|
||||
`--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, url,
|
||||
], { stdio: 'ignore' });
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
let ws;
|
||||
|
||||
try {
|
||||
let target;
|
||||
for (let i = 0; i < 100 && !target; i++) {
|
||||
await sleep(100);
|
||||
try {
|
||||
target = (await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json()))
|
||||
.find(t => t.type === 'page' && t.url.startsWith(url));
|
||||
} catch { /* Chromium is still starting. */ }
|
||||
}
|
||||
assert(target, 'browser exposes the editor page');
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
||||
|
||||
let serial = 0;
|
||||
const pending = new Map();
|
||||
const errors = [];
|
||||
ws.onmessage = ({ data }) => {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.method === 'Runtime.exceptionThrown') errors.push(msg.params.exceptionDetails);
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const waiting = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
if (msg.error) waiting.reject(new Error(JSON.stringify(msg.error)));
|
||||
else waiting.resolve(msg.result);
|
||||
}
|
||||
};
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++serial;
|
||||
pending.set(id, { resolve, reject });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const evaluate = async expression => {
|
||||
const result = await send('Runtime.evaluate', {
|
||||
expression, returnByValue: true, awaitPromise: true,
|
||||
});
|
||||
if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails));
|
||||
return result.result.value;
|
||||
};
|
||||
|
||||
await send('Runtime.enable');
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (await evaluate('typeof arthur !== "undefined" && !!document.querySelector("canvas.stage")')) break;
|
||||
await sleep(100);
|
||||
}
|
||||
await evaluate(`(() => {
|
||||
const k = cljs.core.keyword;
|
||||
cljs.core.swap_BANG_(re_frame.db.app_db, db => cljs.core.assoc(db, k('route'), k('local-test')));
|
||||
window.laneSnapshot = () => {
|
||||
const db = cljs.core.deref(re_frame.db.app_db);
|
||||
return cljs.core.clj__GT_js(arthur.footage.store.entry(cljs.core.get(db, k('clip/current'))));
|
||||
};
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(250);
|
||||
|
||||
assert.equal(await evaluate(`getComputedStyle(document.querySelector('.onion-controls summary')).listStyleType`), 'none');
|
||||
assert.equal(await evaluate(`document.querySelector('.onion-controls summary').textContent`), '▾');
|
||||
await evaluate(`document.querySelector('.onion-controls > button').click()`);
|
||||
await sleep(150);
|
||||
assert.equal(await evaluate(`document.querySelector('.onion-controls > button').getAttribute('aria-pressed')`), 'true');
|
||||
await evaluate(`document.querySelector('.onion-controls summary').click()`);
|
||||
await sleep(100);
|
||||
assert.equal(await evaluate(`document.querySelector('.onion-controls details').open`), true);
|
||||
await evaluate(`(() => {
|
||||
const c = cljs.core, k = c.keyword, m = (...xs) => c.hash_map(...xs), v = (...xs) => c.vector(...xs);
|
||||
const db = c.deref(re_frame.db.app_db);
|
||||
const entry = arthur.footage.store.entry(c.get(db, k('clip/current')));
|
||||
const base = c.get(entry, k('clip'));
|
||||
const cel = (id, drawing, at) => m(k('id'), k(id), k('kind'), k('instance'), k('z'), id,
|
||||
k('source'), m(k('symbol'), k(drawing)), k('span'), v(0, 2),
|
||||
k('time'), m(k('at'), at, k('rate'), 1),
|
||||
k('playback'), m(k('in'), 0, k('speed'), 0, k('end'), k('stop')));
|
||||
const drawing = (id, x) => m(k('id'), k(id), k('frames'), 1, k('nodes'),
|
||||
m(k('mark'), m(k('id'), k('mark'), k('kind'), k('rect'), k('z'), 'a',
|
||||
k('channels'), m(v(k('style'), k('color')), arthur.domain.channel.framed(1), v(k('geom'), k('size')), arthur.domain.channel.framed(6),
|
||||
v(k('xform'), k('pos')), arthur.domain.channel.framed(v(x, 10))))));
|
||||
const symbols = m(k('main'), m(k('id'), k('main'), k('frames'), 6, k('display'), k('lane'),
|
||||
k('nodes'), m(k('a'), cel('a','ink-a',0), k('b'), cel('b','ink-b',2), k('c'), cel('c','ink-c',4))),
|
||||
k('ink-a'), drawing('ink-a',10), k('ink-b'), drawing('ink-b',20), k('ink-c'), drawing('ink-c',30));
|
||||
const doc = c.assoc(base, k('symbols'), symbols);
|
||||
const id = arthur.footage.store.install_BANG_(c.assoc(entry,k('clip'),doc),'onion-test');
|
||||
c.swap_BANG_(re_frame.db.app_db, db => {
|
||||
db = c.assoc(db,k('clip/current'),id);
|
||||
db = c.assoc_in(db,v(k('ui'),k('open')),k('main'));
|
||||
db = c.assoc_in(db,v(k('ui'),k('solo')),c.hash_map());
|
||||
db = c.assoc_in(db,v(k('playback'),k('frame')),2);
|
||||
db = c.assoc_in(db,v(k('playback'),k('playing?')),false);
|
||||
return db;
|
||||
});
|
||||
re_frame.core.dispatch_sync(v(k('arthur.ui.layout/onion'),k('on?'),true));
|
||||
arthur.ui.player.refresh_subs_BANG_();
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(300);
|
||||
const pixels = await evaluate(`(() => {
|
||||
const ctx = document.querySelector('canvas.onion-skin').getContext('2d');
|
||||
return [10,20,30].map(x => Array.from(ctx.getImageData(x,10,1,1).data));
|
||||
})()`);
|
||||
assert(pixels[0][0] > pixels[0][2] && pixels[0][3] > 0, 'previous cel is red');
|
||||
assert.equal(pixels[1][3], 0, 'current cel is excluded');
|
||||
assert(pixels[2][2] > pixels[2][0] && pixels[2][3] > 0, 'next cel is blue');
|
||||
const playbackAlpha = await evaluate(`(() => {
|
||||
const c=cljs.core, k=c.keyword;
|
||||
c.swap_BANG_(re_frame.db.app_db, db => c.assoc_in(db,c.vector(k('playback'),k('playing?')),true));
|
||||
arthur.ui.player.refresh_subs_BANG_();
|
||||
arthur.ui.player.paint_BANG_(2);
|
||||
return document.querySelector('canvas.onion-skin').getContext('2d').getImageData(10,10,1,1).data[3];
|
||||
})()`);
|
||||
assert.equal(playbackAlpha, 0, 'ghosts clear during playback');
|
||||
const preferences = await evaluate(`(() => {
|
||||
const c=cljs.core,k=c.keyword, read=()=>c.get(c.deref(re_frame.core.subscribe(c.vector(k('arthur.ui.layout/onion')))),k('on?'));
|
||||
c.swap_BANG_(re_frame.db.app_db,db=>c.assoc_in(db,c.vector(k('ui'),k('open')),k('ink-a')));
|
||||
const other = read();
|
||||
c.swap_BANG_(re_frame.db.app_db,db=>c.assoc_in(db,c.vector(k('ui'),k('open')),k('main')));
|
||||
return [other,read()];
|
||||
})()`);
|
||||
assert.deepEqual(preferences, [false,true], 'preferences are remembered per symbol');
|
||||
assert.equal(errors.length, 0, JSON.stringify(errors));
|
||||
console.log('PASS: onion controls, ghost colors, current-cel exclusion, playback hiding, per-symbol preferences');
|
||||
} finally {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.close();
|
||||
chrome.kill('SIGTERM');
|
||||
await new Promise(resolve => chrome.once('exit', resolve));
|
||||
try {
|
||||
rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOTEMPTY') throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -1737,3 +1737,16 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
|
|||
.view-popout input { width: 100%; }
|
||||
.passepartout-controls summary { cursor: pointer; }
|
||||
.stage-wrap { flex: 0 0 auto; }
|
||||
|
||||
.onion-skin { position: absolute; inset: 0; pointer-events: none; image-rendering: pixelated; }
|
||||
.onion-controls { display: inline-flex; align-items: center; }
|
||||
.onion-controls > button { border-radius: 2px 0 0 2px; }
|
||||
.onion-controls summary {
|
||||
display: block; cursor: pointer; list-style: none;
|
||||
color: var(--fg); background: var(--pane); border: 1px solid var(--line);
|
||||
border-radius: 0 2px 2px 0; margin-left: -1px; padding: 1px 5px;
|
||||
}
|
||||
.onion-controls summary:hover, .onion-controls details[open] > summary { background: #fff; }
|
||||
.onion-controls summary::-webkit-details-marker { display: none; }
|
||||
.onion-controls .view-popout { line-height: 1.4; }
|
||||
.onion-controls select { width: 100%; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue