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
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ to decode. The blade itself looking for `.tl__cue`, a class that has never
existed. A panel dropped into the empty column landing nowhere, because tearing
down the drag collapsed that column before the drop was worked out. Six panel
tabs overflowing their column into a scrollbar that had been switched off, so
two panels could not be opened at all. None of those is reachable from Node,
two panels could not be opened at all. Every tool writing a point into a
layer's automation, because the handler that routes them stepped aside for a
curve lane and the curve editor asks only whether the pen is held — so panning
across an open layer edited the piece. None of those is reachable from Node,
none is a type error, and every one of them shipped.

Each test names the fault it is for. `test/browser/app.ts` holds what they all
Expand All @@ -108,11 +111,20 @@ seconds of canvas recorded through `MediaRecorder` in the page — about ninety
kilobytes, made in under a second, and no binary in the repository that
somebody has to take on trust.

`tool-scope.spec.ts` is the odd one out and worth knowing about: it is a tool
crossed with a surface rather than a tool on its own, because the tools were
right about what they do and wrong about where. Every case is "this tool, on
that surface, does this and nothing else".

Every test here was checked the same way as the ones above, by putting the
fault back and watching it fail, and writing them found two more: the transport
was hiding its last three controls behind a scrollbar it had disabled, and the
frame rate measurement that runs just after a clip loads was undoing a play
started while it ran.
fault back and watching it fail, and writing them found four more: the
transport was hiding its last three controls behind a scrollbar it had
disabled; the frame rate measurement that runs just after a clip loads was
undoing a play started while it ran; the picture could be dragged off its own
frame, because the pan was clamped against the stage box rather than the
video's and the stage carries fourteen pixels of padding; and zooming back out
never quite reached Fit, since 2.56 divided by 1.6 twice is 1.0000000000000002
rather than 1.

What is not tested yet: the parts that reach the audio graph.

Expand Down
76 changes: 61 additions & 15 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,41 @@ export function mountApp(root: HTMLElement, options: AudioEngineOptions = {}): (
*/
root.appendChild(el('div', { class: 'frame' }, [keepNotice.el, shell]));

/*
* The tool the pointer is holding, written once for the whole screen.
*
* An attribute on the shell rather than a cursor set on each of the dozen
* surfaces underneath, so a tool cannot be half applied: whatever is under
* the pointer, the shape it takes says which tool is held. It sat on the
* timeline until the picture gained a zoom of its own, and a stage that is
* not inside the timeline cannot read an attribute that is.
*/
const writeTool = (state: AppState): void => {
shell.dataset.tool = state.tool;
};
// Written now as well as on every change: subscribing does not deliver the
// state as it stands, and a shell with no tool on it at all is a shell that
// matches "not the move tool".
writeTool(session.store.state);
session.store.subscribe(writeTool);

/*
* Alt, watched only so the zoom tool can say which way it will go.
*
* On the window rather than on a panel because a modifier held down before
* the pointer arrives is the common case, and an element only hears about
* keys while it has focus. Both edges are needed: releasing alt somewhere
* else would otherwise leave the cursor promising a zoom out that is no
* longer what a click does. Blur clears it for the same reason -- alt is
* often what took the window away.
*/
const readAlt = (event: KeyboardEvent): void => {
shell.classList.toggle('is-alt', event.altKey);
};
window.addEventListener('keydown', readAlt);
window.addEventListener('keyup', readAlt);
window.addEventListener('blur', () => shell.classList.remove('is-alt'));

/**
* Whether the video is floating, which is the one thing that changes the
* layout: with the clip in a window the stage above the lanes is not just
Expand Down Expand Up @@ -438,16 +473,18 @@ function editKey(soundDesign: SoundDesignSession, event: KeyboardEvent): boolean
* Holding shift moves the selected sound instead, so a hit that feels late
* can be pulled back without losing your place.
*
* The letters are shared, and the record button decides who has them.
* Some letters are shared, and the record button decides who has those.
*
* There are thirteen drum pads on the letter keys and an editor wants those
* same letters for its tools: T, H, J, K, L and S were claimed by both. That
* is not a clash to arbitrate key by key, it is two modes -- you are either
* playing something in or you are editing, and never both in the same
* keystroke. Record already said as much on its own tooltip and then did
* nothing at all, so it is what says which. Armed, the letters are drums;
* otherwise they are tools, which is what somebody arriving from an edit
* suite will try first.
* There are thirteen drum pads on the letter keys and an editor wants some of
* the same letters for its tools. Six are claimed twice: T, H, J, K, L and S.
* You are either playing something in or you are editing, never both in the
* same keystroke, and record already said as much on its own tooltip -- so it
* is what settles those six. Armed they are drums; otherwise they are tools,
* which is what somebody arriving from an edit suite will try first.
*
* Only those six, though. C, V, Z and P are not pads, and arming once took
* them anyway: reaching for the blade with record on did nothing at all and
* said nothing about why. A clash is settled where there is one.
*/
function soundDesignKey(
session: Session,
Expand Down Expand Up @@ -510,15 +547,24 @@ function soundDesignKey(
return;
}

/* ---- armed: the letters are drums ---- */

/*
* Armed: the letters that are drums are drums. The rest are still tools.
*
* This used to swallow every letter while the record button was on, which
* is more than the clash needs. Only six letters are claimed twice -- T, H,
* J, K, L and S -- and C, V, Z and P are not pads at all, so arming meant
* giving up four tools to a conflict they were never in. Reaching for the
* blade with record on did nothing and said nothing.
*/
if (session.state.armed) {
const pad = PAD_KEYS[lower];
if (pad && !event.repeat) {
event.preventDefault();
soundDesign.addCueAtPlayhead({ kind: 'kit', name: pad });
if (pad) {
if (!event.repeat) {
event.preventDefault();
soundDesign.addCueAtPlayhead({ kind: 'kit', name: pad });
}
return;
}
return;
}

/* ---- otherwise: the letters are an editor's ---- */
Expand Down
4 changes: 2 additions & 2 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ export const TOOLS = [
{ id: 'move', key: 'V', name: 'Move', job: 'Choose sounds, drag them, drag their edges to change how long they are' },
{ id: 'range', key: 'T', name: 'Range', job: 'Drag out a stretch of time. Delete clears every sound inside it' },
{ id: 'cut', key: 'C', name: 'Cut', job: 'Click a sound to cut it short at that point' },
{ id: 'hand', key: 'H', name: 'Hand', job: 'Drag the timeline along without moving anything on it' },
{ id: 'zoom', key: 'Z', name: 'Zoom', job: 'Click to go in, alt-click to go out, drag to fill the width with a stretch' },
{ id: 'hand', key: 'H', name: 'Hand', job: 'Drag the timeline along, or the picture once you have gone into it. Nothing on either moves' },
{ id: 'zoom', key: 'Z', name: 'Zoom', job: 'Click to go in, alt-click to go out. On the timeline, drag to fill the width with a stretch; on the picture, Fit gets the whole frame back' },
{ id: 'pen', key: 'P', name: 'Pen', job: 'Draw a curve by dragging across an open lane, instead of placing points one at a time' },
] as const;

Expand Down
109 changes: 96 additions & 13 deletions src/styles/sound-design.css
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,35 @@
place-items: center;
background: var(--stage);
padding: 14px;
/* A picture taken in past Fit is larger than the stage by definition, and
without this it spills over the timeline below it. */
overflow: hidden;
}

/*
* The magnification, and the way back from it.
*
* Only there while it is saying something: at Fit the picture is whole and a
* chip reading "100% · Fit" is a control that does nothing next to a label
* nobody needs. Bottom right rather than over the middle of the frame, and
* faint until it is pointed at, because it sits on top of the work.
*/
.vstage__fit {
position: absolute;
right: 20px;
bottom: 20px;
z-index: 2;
display: none;
opacity: 0.72;
}

.vstage.is-zoomed .vstage__fit {
display: inline-flex;
}

.vstage__fit:hover,
.vstage__fit:focus-visible {
opacity: 1;
}

.vstage.is-over {
Expand Down Expand Up @@ -2070,8 +2099,8 @@
* which is both the default arrow somebody expects from a selection tool and
* more informative than one shape over everything.
*/
.tl[data-tool='range'] .tl__viewport,
.tl[data-tool='range'] .tl__viewport * {
.app[data-tool='range'] .tl__viewport,
.app[data-tool='range'] .tl__viewport * {
cursor: text;
}

Expand All @@ -2094,8 +2123,8 @@
* says so. Both of these shipped in that state until an image was built from
* the computed value to see whether it actually loaded.
*/
.tl[data-tool='cut'] .tl__viewport,
.tl[data-tool='cut'] .tl__viewport * {
.app[data-tool='cut'] .tl__viewport,
.app[data-tool='cut'] .tl__viewport * {
cursor: url('data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2222%22%20height=%2222%22%20viewBox=%220%200%2022%2022%22%3E%3Cpath%20d=%22M7.4%202h4.2l1.3%208.4H6.1z%22%20fill=%22white%22%20stroke=%22black%22%20stroke-width=%221.2%22%20stroke-linejoin=%22round%22/%3E%3Cpath%20d=%22M9.5%2011.4V20%22%20fill=%22none%22%20stroke=%22black%22%20stroke-width=%222.6%22%20stroke-linecap=%22round%22/%3E%3Cpath%20d=%22M9.5%2011.4V20%22%20fill=%22none%22%20stroke=%22white%22%20stroke-width=%221.2%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E') 9 20, crosshair;
}

Expand All @@ -2110,17 +2139,17 @@
* pointer events is not in the event's path at all, so the press arrives
* addressed to the empty lane behind it.
*/
.tl[data-tool='cut'] .cue {
.app[data-tool='cut'] .cue {
pointer-events: auto;
}

.tl[data-tool='hand'] .tl__viewport,
.tl[data-tool='hand'] .tl__viewport * {
.app[data-tool='hand'] .tl__viewport,
.app[data-tool='hand'] .tl__viewport * {
cursor: grab;
}

.tl[data-tool='zoom'] .tl__viewport,
.tl[data-tool='zoom'] .tl__viewport * {
.app[data-tool='zoom'] .tl__viewport,
.app[data-tool='zoom'] .tl__viewport * {
cursor: zoom-in;
}

Expand All @@ -2130,8 +2159,8 @@
* The tool does both jobs off one button, so without this the only way to
* find out which one a click will do is to do it.
*/
.tl[data-tool='zoom'].is-alt .tl__viewport,
.tl[data-tool='zoom'].is-alt .tl__viewport * {
.app[data-tool='zoom'].is-alt .tl__viewport,
.app[data-tool='zoom'].is-alt .tl__viewport * {
cursor: zoom-out;
}

Expand All @@ -2140,6 +2169,60 @@
cursor: grabbing;
}

/*
* The picture answers to the same two tools, and says so.
*
* Written against the app rather than the timeline, because the stage is not
* inside the timeline and the tool is a property of the whole screen. The
* other three are deliberately absent: there is nothing on a frame for a
* blade, a time range or a pen to act on, so they keep the plain arrow and
* the press says as much in the status line.
*/
.app[data-tool='hand'] .vstage,
.app[data-tool='hand'] .vstage * {
cursor: grab;
}

.vstage.is-grabbing,
.vstage.is-grabbing * {
cursor: grabbing;
}

.app[data-tool='zoom'] .vstage,
.app[data-tool='zoom'] .vstage * {
cursor: zoom-in;
}

.app[data-tool='zoom'].is-alt .vstage,
.app[data-tool='zoom'].is-alt .vstage * {
cursor: zoom-out;
}

/* The Fit chip is a button whatever is held, since it is chrome over the
picture rather than part of it. */
.vstage__fit,
.app[data-tool] .vstage__fit {
cursor: pointer;
}

/*
* The layer column keeps its own pointers, whatever tool is held.
*
* Renaming a layer, muting it and opening its curves work under every tool,
* the way a track header does in an edit suite -- so the column is not a
* surface the tools apply to. What it must not do is claim otherwise: it was
* showing a text caret over every layer name while the hand or the blade was
* held, promising a rename to a pointer that was about to pan.
*/
.app:not([data-tool='move']) .tl__gutter,
.app:not([data-tool='move']) .tl__gutter .tl__layer-name {
cursor: default;
}

.app:not([data-tool='move']) .tl__gutter button {
cursor: pointer;
}


/* ---------- context menus ---------- */

Expand Down Expand Up @@ -2242,8 +2325,8 @@
cursor: crosshair;
}

.tl[data-tool='pen'] .tl__viewport,
.tl[data-tool='pen'] .tl__viewport * {
.app[data-tool='pen'] .tl__viewport,
.app[data-tool='pen'] .tl__viewport * {
cursor: url('data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2222%22%20height=%2222%22%20viewBox=%220%200%2022%2022%22%3E%3Cpath%20d=%22M15.4%202.2%2019%205.8%208.2%2016.6l-4.6%201%201-4.6z%22%20fill=%22white%22%20stroke=%22black%22%20stroke-width=%221.2%22%20stroke-linejoin=%22round%22/%3E%3Cpath%20d=%22M12.6%205%2016.2%208.6%22%20fill=%22none%22%20stroke=%22black%22%20stroke-width=%221.1%22/%3E%3C/svg%3E') 3 19, crosshair;
}

Expand Down
11 changes: 7 additions & 4 deletions src/ui/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,13 @@ const SECTIONS: readonly Section[] = [
['Move (V)', 'Choose sounds, drag them along, and drag a sound’s far edge to change how long it lasts. This is what the timeline has always done, and it is what you start on.'],
['Range (T)', 'Drag out a stretch of time across the lanes. Delete clears every sound that starts inside it. Escape lets it go.'],
['Cut (C)', 'Click a sound anywhere along its length to cut it short there. A sound anchored to its end keeps landing on its marker and loses its front instead.'],
['Hand (H)', 'Drag the timeline along without moving anything on it.'],
['Zoom (Z)', 'Click to go in, hold alt and click to go out, or drag across a stretch to fill the width with it.'],
['The letters', 'V, T, C, H and Z change tool, the way they do in an edit suite. They are printed on the buttons so you only have to read them once.'],
['While record is armed', 'The letters play the drums instead, because thirteen drum pads and five tools want the same keys. Record decides which. Disarm it and the letters are tools again.'],
['Hand (H)', 'Drag the timeline along, or the picture once you have gone into it. Nothing on either one moves — only your view of it does.'],
['Zoom (Z)', 'Click to go in, hold alt and click to go out. On the timeline you can also drag across a stretch to fill the width with it. On the picture, a chip in the corner says how far in you are and takes you back to the whole frame.'],
['Pen (P)', 'Draw a curve by dragging along an open lane, instead of placing points one at a time. Open a layer’s lanes with the A beside its name.'],
['Where each one works', 'The hand and the zoom work on the timeline and on the picture. The other three are about the timeline: used on the picture they say so rather than doing something surprising. Only Move chooses sounds — that is the whole point of having a tool for it.'],
['The layer names, down the left', 'These work whatever tool you are holding, the way a track header does in an edit suite: rename, mute, solo and open the curves without putting the tool down.'],
['The letters', 'V, T, C, H, Z and P change tool, the way they do in an edit suite. They are printed on the buttons so you only have to read them once.'],
['While record is armed', 'The letters that are also drum pads play the drums instead — T, H, J, K, L and S are wanted by both, and record decides which. The rest are still tools: C, V, Z and P are not pads, so they keep working. Disarm it and all the letters are tools again.'],
],
},
{
Expand Down
Loading
Loading