Reorderable columns, a loaded first file, and a spectrogram worth keeping
Drag a heading and the column moves, with a copy of it following the pointer and the column it came from stepping back. One array of keys — every column, shown or hidden — backs the table, the Settings list and what is saved, so a hidden column keeps its neighbour and returns where it was rather than at the end. A drag ends in a click, and the click is what sorting listens for, so that click is swallowed. Opening a folder loads its first file: the read, the peaks, the channel chips and the waveform, then paused at the start rather than playing. playRow gained a paused mode instead of a second loader that could drift from it. It cannot stop a folder opening, and a file that will not decode no longer writes a playback error over the line saying the folder opened — nobody asked it to play. The badge follows the transport now, so a paused file stops claiming to be playing. A bulk run re-reads every file it touches, but nothing told the transport to look again: rename the boom across a card and the mixer went on showing the old name for the very file it had just rewritten. Settings loses its Done button and the mixer its Close — both apply as you touch them, and the window's own close is enough. Save Image moves onto the file name's line; the rule meant to do that had been dead for some time, overwritten by the shared modal header. The exported spectrogram carries the folder, the file and the frequency axis, drawn in, because a PNG read somewhere else has none of that. The viewer is unchanged. Mixer track names wear the player's pill, the waveform thumbnails give their grey panel back to the row, and every button is the transport's size. Build output that had been committed by accident is untracked, and .gitignore grows the entries for it. 459 checks.
This commit is contained in:
+13
@@ -13,3 +13,16 @@ node_modules/
|
||||
/mac-app/dist/index.html
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Python bytecode and virtualenvs. build/build.py imports build/icons.py, so
|
||||
# a __pycache__ appears next to it on every build.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Generic build output, alongside the specific paths above.
|
||||
dist/
|
||||
|
||||
# Local-only settings: machine-specific, and not the project's business.
|
||||
.claude/settings.local.json
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+1
-5
@@ -165,7 +165,6 @@
|
||||
<button type="button" class="btn btn-sm btn-outline"
|
||||
data-bwfa-mixer-next aria-label="Next file">›</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline" data-bwfa-mixer-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,9 +177,9 @@
|
||||
aria-label="Close">×</button>
|
||||
<div class="bwfa-spectro-head">
|
||||
<h4 data-bwfa-spectro-title>Spectrogram</h4>
|
||||
<p data-bwfa-spectro-note></p>
|
||||
<button type="button" class="btn btn-sm btn-outline bwfa-spectro-save"
|
||||
data-bwfa-spectro-save>Save Image</button>
|
||||
<p data-bwfa-spectro-note></p>
|
||||
</div>
|
||||
<div class="bwfa-spectro-plot">
|
||||
<div class="bwfa-spectro-scale" data-bwfa-spectro-scale></div>
|
||||
@@ -274,9 +273,6 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bwfa-columns-close>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+523
-1
@@ -1566,6 +1566,9 @@ SPECTRO_PATCHES = [
|
||||
\t\tvar spectroNote = container.querySelector( "[data-bwfa-spectro-note]" );
|
||||
\t\tvar spectroTitle = container.querySelector( "[data-bwfa-spectro-title]" );
|
||||
\t\tvar spectroScale = container.querySelector( "[data-bwfa-spectro-scale]" );
|
||||
\t\t// Kept so the exported image can label its own frequency axis. On
|
||||
\t\t// screen the axis is HTML beside the canvas; a PNG has to carry it.
|
||||
\t\tvar spectroPicture = null;
|
||||
|
||||
\t\t/**
|
||||
\t\t * Viridis, sampled. Perceptually even, so a bright patch means a loud
|
||||
@@ -1602,6 +1605,7 @@ SPECTRO_PATCHES = [
|
||||
\t\t\tif ( ! ctx ) {
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\tspectroPicture = picture;
|
||||
\t\t\tspectroCanvas.width = picture.columns;
|
||||
\t\t\tspectroCanvas.height = picture.bins;
|
||||
\t\t\tvar image = ctx.createImageData( picture.columns, picture.bins );
|
||||
@@ -1636,6 +1640,81 @@ SPECTRO_PATCHES = [
|
||||
\t\t\t} );
|
||||
\t\t}
|
||||
|
||||
\t\t/**
|
||||
\t\t * The picture as something worth keeping: the file it came from, the
|
||||
\t\t * reading underneath it, and the frequency axis, drawn into the image
|
||||
\t\t * rather than sitting beside it in HTML.
|
||||
\t\t *
|
||||
\t\t * A PNG leaves the app and is looked at somewhere else, where none of
|
||||
\t\t * that context survives — a bare spectrogram is a pretty picture of an
|
||||
\t\t * unknown file at an unknown scale. On screen it is all still there in
|
||||
\t\t * the modal, so the viewer keeps exactly the layout it has.
|
||||
\t\t *
|
||||
\t\t * Black on white regardless of the app's own theme: this is a figure to
|
||||
\t\t * be sent, printed or dropped into a report.
|
||||
\t\t */
|
||||
\t\tfunction buildSpectroExport( row ) {
|
||||
\t\t\tif ( ! spectroPicture || ! spectroCanvas.width ) {
|
||||
\t\t\t\treturn null;
|
||||
\t\t\t}
|
||||
\t\t\tvar gutter = 74;
|
||||
\t\t\tvar header = 54;
|
||||
\t\t\tvar pad = 14;
|
||||
\t\t\tvar plotWidth = spectroCanvas.width;
|
||||
\t\t\t// The bins are far shorter than the columns, and the modal stretches
|
||||
\t\t\t// them to fit. Do the same rather than exporting a 1200×512 sliver.
|
||||
\t\t\tvar plotHeight = 460;
|
||||
|
||||
\t\t\tvar out = document.createElement( "canvas" );
|
||||
\t\t\tout.width = gutter + plotWidth + pad;
|
||||
\t\t\tout.height = header + plotHeight + pad;
|
||||
\t\t\tvar ctx = out.getContext( "2d" );
|
||||
\t\t\tif ( ! ctx ) {
|
||||
\t\t\t\treturn null;
|
||||
\t\t\t}
|
||||
\t\t\tvar sans = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif';
|
||||
|
||||
\t\t\tctx.fillStyle = "#ffffff";
|
||||
\t\t\tctx.fillRect( 0, 0, out.width, out.height );
|
||||
|
||||
\t\t\t// The folder as well as the file: a take number on its own names
|
||||
\t\t\t// nothing once the picture is out of the app.
|
||||
\t\t\tvar folder = window.BWFA_FOLDER_NAME || "";
|
||||
\t\t\tvar fileName = ( row && row.parsed && row.parsed.fileName ) || "";
|
||||
\t\t\tctx.fillStyle = "#111111";
|
||||
\t\t\tctx.font = "600 19px " + sans;
|
||||
\t\t\tctx.textBaseline = "alphabetic";
|
||||
\t\t\tctx.fillText( folder ? ( folder + " / " + fileName ) : fileName, gutter, 26 );
|
||||
\t\t\tctx.fillStyle = "#555555";
|
||||
\t\t\tctx.font = "13px " + sans;
|
||||
\t\t\tctx.fillText( spectroNote.textContent || "", gutter, 45 );
|
||||
|
||||
\t\t\t// The picture itself, unsmoothed for the same reason the modal
|
||||
\t\t\t// doesn't smooth it: it would invent detail the file doesn't have.
|
||||
\t\t\tctx.imageSmoothingEnabled = false;
|
||||
\t\t\tctx.drawImage( spectroCanvas, gutter, header, plotWidth, plotHeight );
|
||||
|
||||
\t\t\t// And the axis it is read against, on the same five marks the modal
|
||||
\t\t\t// shows: Nyquist at the top, DC at the bottom.
|
||||
\t\t\tvar top = spectroPicture.sampleRate / 2;
|
||||
\t\t\tctx.fillStyle = "#555555";
|
||||
\t\t\tctx.font = "12px " + sans;
|
||||
\t\t\tctx.textAlign = "right";
|
||||
\t\t\t[ 1, 0.75, 0.5, 0.25, 0 ].forEach( function ( part ) {
|
||||
\t\t\t\tvar hz = top * part;
|
||||
\t\t\t\tvar label = hz >= 1000
|
||||
\t\t\t\t\t? ( Math.round( hz / 100 ) / 10 ) + " kHz"
|
||||
\t\t\t\t\t: Math.round( hz ) + " Hz";
|
||||
\t\t\t\tvar y = header + ( 1 - part ) * plotHeight;
|
||||
\t\t\t\t// Nudged inside the plot at the ends so neither mark is clipped.
|
||||
\t\t\t\tvar baseline = Math.max( header + 10, Math.min( header + plotHeight, y + 4 ) );
|
||||
\t\t\t\tctx.fillText( label, gutter - 10, baseline );
|
||||
\t\t\t} );
|
||||
\t\t\tctx.textAlign = "left";
|
||||
|
||||
\t\t\treturn out;
|
||||
\t\t}
|
||||
|
||||
\t\tfunction openSpectro() {
|
||||
\t\t\tvar row = playerRow();
|
||||
\t\t\tif ( ! row || ! row.file ) {
|
||||
@@ -1682,7 +1761,7 @@ SPECTRO_PATCHES = [
|
||||
\t\t\t\tvar row = playerRow();
|
||||
\t\t\t\tvar stem = row && row.parsed
|
||||
\t\t\t\t\t? row.parsed.fileName.replace( /\\.[^.]*$/, "" ) : "spectrogram";
|
||||
\t\t\t\tspectroCanvas.toBlob( function ( blob ) {
|
||||
\t\t\t\t( buildSpectroExport( row ) || spectroCanvas ).toBlob( function ( blob ) {
|
||||
\t\t\t\t\tif ( ! blob ) {
|
||||
\t\t\t\t\t\treturn;
|
||||
\t\t\t\t\t}
|
||||
@@ -2584,7 +2663,448 @@ PLAYER_PATCHES = [
|
||||
),
|
||||
]
|
||||
|
||||
# ---- the order of the columns belongs to whoever is looking ------------
|
||||
#
|
||||
# Drag a heading and the column moves. Applied last, so its anchors are the
|
||||
# markup as every other patch leaves it.
|
||||
#
|
||||
# Three things have to agree: the table, the Settings list and what is on
|
||||
# disk. All three read one array of keys — every column, shown or hidden —
|
||||
# so there is no second copy to fall out of step.
|
||||
COLUMN_ORDER_PATCHES = [
|
||||
(
|
||||
"""\tfunction getDefaultVisibleColumnKeys( columns ) {""",
|
||||
"""\tvar COLUMN_ORDER_STORAGE_KEY = "bwfa_column_order_v1";
|
||||
|
||||
\t/**
|
||||
\t * The order the columns are shown in, as a list of keys.
|
||||
\t *
|
||||
\t * Kept for every column, hidden ones included. Switching a column off and
|
||||
\t * on again should put it back where it was rather than at the end, and
|
||||
\t * dragging past a hidden column should not shunt it elsewhere.
|
||||
\t */
|
||||
\tfunction loadColumnOrder( columns, storage ) {
|
||||
\t\tvar defaults = columns.map( function ( col ) { return col.key; } );
|
||||
\t\tif ( ! storage ) {
|
||||
\t\t\treturn defaults;
|
||||
\t\t}
|
||||
\t\ttry {
|
||||
\t\t\tvar parsed = JSON.parse( storage.getItem( COLUMN_ORDER_STORAGE_KEY ) || "null" );
|
||||
\t\t\tif ( ! Array.isArray( parsed ) ) {
|
||||
\t\t\t\treturn defaults;
|
||||
\t\t\t}
|
||||
\t\t\t// A key that is no longer a column is dropped; a column added to
|
||||
\t\t\t// the app since this was saved is slotted in where it ships, rather
|
||||
\t\t\t// than appearing at the far end of somebody's saved order.
|
||||
\t\t\tvar kept = parsed.filter( function ( key ) {
|
||||
\t\t\t\treturn defaults.indexOf( key ) !== -1;
|
||||
\t\t\t} );
|
||||
\t\t\tdefaults.forEach( function ( key, index ) {
|
||||
\t\t\t\tif ( kept.indexOf( key ) === -1 ) {
|
||||
\t\t\t\t\tkept.splice( Math.min( index, kept.length ), 0, key );
|
||||
\t\t\t\t}
|
||||
\t\t\t} );
|
||||
\t\t\treturn kept;
|
||||
\t\t} catch ( e ) {
|
||||
\t\t\treturn defaults;
|
||||
\t\t}
|
||||
\t}
|
||||
|
||||
\tfunction saveColumnOrder( order, storage ) {
|
||||
\t\tif ( ! storage ) {
|
||||
\t\t\treturn;
|
||||
\t\t}
|
||||
\t\ttry {
|
||||
\t\t\tstorage.setItem( COLUMN_ORDER_STORAGE_KEY, JSON.stringify( order ) );
|
||||
\t\t} catch ( e ) {
|
||||
\t\t\t// As with the visible-column picker: it works for this session and
|
||||
\t\t\t// simply isn't remembered. Not fatal.
|
||||
\t\t}
|
||||
\t}
|
||||
|
||||
\tfunction getDefaultVisibleColumnKeys( columns ) {""",
|
||||
),
|
||||
(
|
||||
"""\t\t\tvisibleColumns: loadVisibleColumns( TABLE_COLUMNS, storage ),
|
||||
\t\t\texportFields: loadExportFields( storage ),""",
|
||||
"""\t\t\tvisibleColumns: loadVisibleColumns( TABLE_COLUMNS, storage ),
|
||||
\t\t\tcolumnOrder: loadColumnOrder( TABLE_COLUMNS, storage ),
|
||||
\t\t\texportFields: loadExportFields( storage ),""",
|
||||
),
|
||||
(
|
||||
"""\t\tfunction visibleColumnList() {
|
||||
\t\t\treturn TABLE_COLUMNS.filter( function ( col ) { return state.visibleColumns.has( col.key ); } );
|
||||
\t\t}""",
|
||||
"""\t\t/** Every column there is, in the order they are shown. */
|
||||
\t\tfunction orderedColumnList() {
|
||||
\t\t\tvar byKey = {};
|
||||
\t\t\tTABLE_COLUMNS.forEach( function ( col ) { byKey[ col.key ] = col; } );
|
||||
\t\t\treturn ( state.columnOrder || [] ).map( function ( key ) {
|
||||
\t\t\t\treturn byKey[ key ];
|
||||
\t\t\t} ).filter( Boolean );
|
||||
\t\t}
|
||||
|
||||
\t\tfunction visibleColumnList() {
|
||||
\t\t\treturn orderedColumnList().filter( function ( col ) {
|
||||
\t\t\t\treturn state.visibleColumns.has( col.key );
|
||||
\t\t\t} );
|
||||
\t\t}
|
||||
|
||||
\t\t/**
|
||||
\t\t * Moves a column so it sits immediately before another, or to the end
|
||||
\t\t * when there is nothing to sit before.
|
||||
\t\t *
|
||||
\t\t * Both are keys, not positions, and the list being reordered is the
|
||||
\t\t * full one. A hidden column keeps whichever neighbour it already had,
|
||||
\t\t * so switching it back on puts it somewhere recognisable.
|
||||
\t\t */
|
||||
\t\tfunction moveColumn( key, beforeKey ) {
|
||||
\t\t\tvar order = ( state.columnOrder || [] ).slice();
|
||||
\t\t\tvar from = order.indexOf( key );
|
||||
\t\t\tif ( from === -1 ) {
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\torder.splice( from, 1 );
|
||||
\t\t\tvar at = beforeKey ? order.indexOf( beforeKey ) : -1;
|
||||
\t\t\tif ( at === -1 ) {
|
||||
\t\t\t\torder.push( key );
|
||||
\t\t\t} else {
|
||||
\t\t\t\torder.splice( at, 0, key );
|
||||
\t\t\t}
|
||||
\t\t\tstate.columnOrder = order;
|
||||
\t\t\tsaveColumnOrder( order, storage );
|
||||
\t\t}""",
|
||||
),
|
||||
(
|
||||
"""\t\tfunction renderTableHead() {""",
|
||||
"""\t\t/* ---- dragging a heading ----------------------------------------
|
||||
\t\t Pointer events, not HTML5 drag-and-drop. The window has Tauri's
|
||||
\t\t native drag-drop switched on so a folder can be dropped onto it,
|
||||
\t\t and on macOS that intercepts the webview's own drag events.
|
||||
|
||||
\t\t It suits the table anyway: no drag image, no insertion line, no
|
||||
\t\t placeholder, nothing added to the page at any point. The columns
|
||||
\t\t themselves move as the pointer passes them, which is the only
|
||||
\t\t feedback there is and the only kind that costs the design nothing.
|
||||
\t\t -------------------------------------------------------------- */
|
||||
|
||||
\t\tvar columnDrag = null;
|
||||
\t\tvar sortClickSuppressed = false;
|
||||
|
||||
\t\t/** The key of the heading under this x position, if any. */
|
||||
\t\tfunction columnKeyAt( clientX ) {
|
||||
\t\t\tvar found = null;
|
||||
\t\t\ttableHead.querySelectorAll( "th[data-bwfa-col]" ).forEach( function ( th ) {
|
||||
\t\t\t\tvar box = th.getBoundingClientRect();
|
||||
\t\t\t\tif ( clientX >= box.left && clientX <= box.right ) {
|
||||
\t\t\t\t\tfound = th.getAttribute( "data-bwfa-col" );
|
||||
\t\t\t\t}
|
||||
\t\t\t} );
|
||||
\t\t\treturn found;
|
||||
\t\t}
|
||||
|
||||
\t\tfunction startColumnDrag( e, key, label ) {
|
||||
\t\t\tif ( e.button !== 0 ) {
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\tcolumnDrag = { key: key, label: label, x: e.clientX, moved: false, ghost: null };
|
||||
\t\t}
|
||||
|
||||
\t\t/** The heading you are holding, following the pointer. */
|
||||
\t\tfunction showColumnGhost( e ) {
|
||||
\t\t\tif ( ! columnDrag.ghost ) {
|
||||
\t\t\t\tvar ghost = document.createElement( "div" );
|
||||
\t\t\t\tghost.className = "bwfa-col-ghost";
|
||||
\t\t\t\tghost.setAttribute( "data-bwfa-col-ghost", columnDrag.key );
|
||||
\t\t\t\tghost.textContent = columnDrag.label;
|
||||
\t\t\t\tcontainer.appendChild( ghost );
|
||||
\t\t\t\tcolumnDrag.ghost = ghost;
|
||||
\t\t\t}
|
||||
\t\t\tcolumnDrag.ghost.style.left = ( e.clientX + 12 ) + "px";
|
||||
\t\t\tcolumnDrag.ghost.style.top = ( e.clientY + 12 ) + "px";
|
||||
\t\t\t// Re-applied after every reorder, because the heading it marks is a
|
||||
\t\t\t// new element by then.
|
||||
\t\t\ttableHead.querySelectorAll( "th[data-bwfa-col]" ).forEach( function ( th ) {
|
||||
\t\t\t\tth.classList.toggle( "is-dragging",
|
||||
\t\t\t\t\tth.getAttribute( "data-bwfa-col" ) === columnDrag.key );
|
||||
\t\t\t} );
|
||||
\t\t}
|
||||
|
||||
\t\tfunction endColumnGhost() {
|
||||
\t\t\tif ( columnDrag && columnDrag.ghost && columnDrag.ghost.parentNode ) {
|
||||
\t\t\t\tcolumnDrag.ghost.parentNode.removeChild( columnDrag.ghost );
|
||||
\t\t\t}
|
||||
\t\t\ttableHead.querySelectorAll( "th.is-dragging" ).forEach( function ( th ) {
|
||||
\t\t\t\tth.classList.remove( "is-dragging" );
|
||||
\t\t\t} );
|
||||
\t\t}
|
||||
|
||||
\t\tdocument.addEventListener( "mousemove", function ( e ) {
|
||||
\t\t\tif ( ! columnDrag ) {
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\t// A few pixels of slack, so a click with a tremor in it still sorts.
|
||||
\t\t\tif ( ! columnDrag.moved && Math.abs( e.clientX - columnDrag.x ) < 4 ) {
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\tcolumnDrag.moved = true;
|
||||
\t\t\tshowColumnGhost( e );
|
||||
\t\t\tvar over = columnKeyAt( e.clientX );
|
||||
\t\t\tif ( ! over || over === columnDrag.key ) {
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\tvar visible = visibleColumnList().map( function ( col ) { return col.key; } );
|
||||
\t\t\tvar from = visible.indexOf( columnDrag.key );
|
||||
\t\t\tvar to = visible.indexOf( over );
|
||||
\t\t\tif ( from === -1 || to === -1 ) {
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\t// Rightwards it lands after the heading it passed, leftwards before
|
||||
\t\t\t// it — so the one under the pointer stays under the pointer.
|
||||
\t\t\tmoveColumn( columnDrag.key, to > from ? ( visible[ to + 1 ] || null ) : over );
|
||||
\t\t\trenderTableHead();
|
||||
\t\t\trenderTableBody();
|
||||
\t\t\trenderColumnsMenu();
|
||||
\t\t\t// The head was just rebuilt, so the heading being dragged has to be
|
||||
\t\t\t// marked again on its new element.
|
||||
\t\t\tshowColumnGhost( e );
|
||||
\t\t} );
|
||||
|
||||
\t\tdocument.addEventListener( "mouseup", function () {
|
||||
\t\t\tif ( ! columnDrag ) {
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\tendColumnGhost();
|
||||
\t\t\t// The click that ends a drag is the end of the drag, not a request
|
||||
\t\t\t// to sort by whatever the pointer came to rest on.
|
||||
\t\t\tsortClickSuppressed = columnDrag.moved;
|
||||
\t\t\tcolumnDrag = null;
|
||||
\t\t\tif ( sortClickSuppressed ) {
|
||||
\t\t\t\twindow.setTimeout( function () { sortClickSuppressed = false; }, 0 );
|
||||
\t\t\t}
|
||||
\t\t} );
|
||||
|
||||
\t\tfunction renderTableHead() {""",
|
||||
),
|
||||
(
|
||||
"""\t\t\t\tvar th = document.createElement( "th" );
|
||||
\t\t\t\tth.textContent = col.label;
|
||||
\t\t\t\tif ( col.sortable ) {
|
||||
\t\t\t\t\tth.setAttribute( "data-bwfa-sort", col.key );
|
||||
\t\t\t\t\tvar indicator = el( "span", "bwfa-sort-indicator", "\\u25B2" );
|
||||
\t\t\t\t\tth.appendChild( indicator );
|
||||
\t\t\t\t\tth.addEventListener( "click", function () {
|
||||
\t\t\t\t\t\tif ( state.sortKey === col.key ) {""",
|
||||
"""\t\t\t\tvar th = document.createElement( "th" );
|
||||
\t\t\t\tth.textContent = col.label;
|
||||
\t\t\t\t// Invisible: somewhere for the drag to take hold, and how the
|
||||
\t\t\t\t// pointer is matched to a heading. Every column can be dragged,
|
||||
\t\t\t\t// including the two that cannot be sorted.
|
||||
\t\t\t\tth.setAttribute( "data-bwfa-col", col.key );
|
||||
\t\t\t\tth.addEventListener( "mousedown", function ( e ) {
|
||||
\t\t\t\t\tstartColumnDrag( e, col.key, col.label );
|
||||
\t\t\t\t} );
|
||||
\t\t\t\tif ( col.sortable ) {
|
||||
\t\t\t\t\tth.setAttribute( "data-bwfa-sort", col.key );
|
||||
\t\t\t\t\tvar indicator = el( "span", "bwfa-sort-indicator", "\\u25B2" );
|
||||
\t\t\t\t\tth.appendChild( indicator );
|
||||
\t\t\t\t\tth.addEventListener( "click", function () {
|
||||
\t\t\t\t\t\tif ( sortClickSuppressed ) {
|
||||
\t\t\t\t\t\t\treturn;
|
||||
\t\t\t\t\t\t}
|
||||
\t\t\t\t\t\tif ( state.sortKey === col.key ) {""",
|
||||
),
|
||||
(
|
||||
"""\t\t\tcolumnsMenu.textContent = "";
|
||||
\t\t\tTABLE_COLUMNS.forEach( function ( col ) {""",
|
||||
"""\t\t\tcolumnsMenu.textContent = "";
|
||||
\t\t\t// In the order the table shows them, so the list reads like the
|
||||
\t\t\t// table rather than like the source file.
|
||||
\t\t\torderedColumnList().forEach( function ( col ) {""",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ---- a folder that opens has its first file on the transport -----------
|
||||
FIRST_FILE_PATCHES = [
|
||||
# playRow gains a paused mode: everything it does to load a file, minus
|
||||
# the two lines that make a sound. Loading is the expensive, fiddly part
|
||||
# — the read, the peaks, the graph, the channel chips — and a second
|
||||
# copy of it that drifted from this one is exactly the bug that would
|
||||
# not show up until someone pressed Play on a primed file.
|
||||
(
|
||||
"""\t\tfunction playRow( row ) {""",
|
||||
"""\t\tfunction playRow( row, startPaused ) {""",
|
||||
),
|
||||
(
|
||||
"""\t\t\tvar playingBadge = container.querySelector( "[data-bwfa-player-badge]" );
|
||||
\t\t\tif ( playingBadge ) {
|
||||
\t\t\t\tplayingBadge.textContent = t( "playerPlaying" );
|
||||
\t\t\t}""",
|
||||
"""\t\t\tvar playingBadge = container.querySelector( "[data-bwfa-player-badge]" );
|
||||
\t\t\tif ( playingBadge ) {
|
||||
\t\t\t\tplayingBadge.textContent = startPaused ? t( "playerReady" ) : t( "playerPlaying" );
|
||||
\t\t\t}""",
|
||||
),
|
||||
(
|
||||
"""\t\t\t\t\tisPlaying: true,""",
|
||||
"""\t\t\t\t\tisPlaying: ! startPaused,""",
|
||||
),
|
||||
# Nobody asked the primed file to play, so a file that won't load must
|
||||
# not shout about it. handlePlaybackError writes to the status line, and
|
||||
# the status line has just said the folder opened — replacing that with
|
||||
# "this file could not be played back" reads as the folder having failed.
|
||||
(
|
||||
"""\t\t\t} ).catch( function () {
|
||||
\t\t\t\tplayerDecoding.hidden = true;
|
||||
\t\t\t\thandlePlaybackError();
|
||||
\t\t\t\tshowIdlePlayer( null );
|
||||
\t\t\t} );""",
|
||||
"""\t\t\t} ).catch( function () {
|
||||
\t\t\t\tplayerDecoding.hidden = true;
|
||||
\t\t\t\tif ( ! startPaused ) {
|
||||
\t\t\t\t\thandlePlaybackError();
|
||||
\t\t\t\t}
|
||||
\t\t\t\tshowIdlePlayer( null );
|
||||
\t\t\t} );""",
|
||||
),
|
||||
# The badge was written once, when a file was started, and never again —
|
||||
# so a file paused from the transport still read "Now playing". Loading a
|
||||
# file up front made that plain: it announced itself as playing the
|
||||
# moment you pressed Play on it, because the word never changed. It
|
||||
# belongs with everything else the transport keeps in step.
|
||||
(
|
||||
"""\t\tfunction syncPlaybackUI() {
|
||||
\t\t\tsyncMixerTransport();
|
||||
\t\t\tsyncMixerStates();""",
|
||||
"""\t\tfunction syncPlaybackUI() {
|
||||
\t\t\tsyncMixerTransport();
|
||||
\t\t\tsyncMixerStates();
|
||||
\t\t\tvar transportBadge = container.querySelector( "[data-bwfa-player-badge]" );
|
||||
\t\t\tif ( transportBadge && state.playback ) {
|
||||
\t\t\t\ttransportBadge.textContent = state.playback.isPlaying
|
||||
\t\t\t\t\t? t( "playerPlaying" ) : t( "playerReady" );
|
||||
\t\t\t}""",
|
||||
),
|
||||
(
|
||||
"""\t\t\tstate.playback.elapsed = 0;
|
||||
\t\t\t\tplayer( "bwf_play", {
|
||||
\t\t\t\t\tpath: row.file.path,
|
||||
\t\t\t\t\toffset: startAt,
|
||||
\t\t\t\t\tgains: state.playback.channelGains || []
|
||||
\t\t\t\t} ).catch( function () { handlePlaybackError(); } );
|
||||
\t\t\t\trenderChannelChips( row );
|
||||
\t\t\t\tfitCanvasResolution( playerWaveformCanvas );
|
||||
\t\t\t\tstartTransportLoop();
|
||||
\t\t\t\tsyncPlaybackUI();""",
|
||||
"""\t\t\tstate.playback.elapsed = 0;
|
||||
\t\t\t\tif ( ! startPaused ) {
|
||||
\t\t\t\t\tplayer( "bwf_play", {
|
||||
\t\t\t\t\t\tpath: row.file.path,
|
||||
\t\t\t\t\t\toffset: startAt,
|
||||
\t\t\t\t\t\tgains: state.playback.channelGains || []
|
||||
\t\t\t\t\t} ).catch( function () { handlePlaybackError(); } );
|
||||
\t\t\t\t}
|
||||
\t\t\t\trenderChannelChips( row );
|
||||
\t\t\t\tfitCanvasResolution( playerWaveformCanvas );
|
||||
\t\t\t\tif ( startPaused ) {
|
||||
\t\t\t\t\t// No loop to draw the first frame, so draw it: the waveform and
|
||||
\t\t\t\t\t// the length are the point of loading it up front.
|
||||
\t\t\t\t\tupdateTransportDisplay();
|
||||
\t\t\t\t} else {
|
||||
\t\t\t\t\tstartTransportLoop();
|
||||
\t\t\t\t}
|
||||
\t\t\t\tsyncPlaybackUI();""",
|
||||
),
|
||||
(
|
||||
"""\t\tfunction setEditableMode( editable ) {""",
|
||||
"""\t\t/**
|
||||
\t\t * Puts the first file of the folder on the transport, ready to play.
|
||||
\t\t *
|
||||
\t\t * Ready, not playing: opening a folder is not a request to make a
|
||||
\t\t * noise. The transport's idle path already knows how to start what it
|
||||
\t\t * is holding, so Play and the spacebar both work from here without
|
||||
\t\t * anything else being wired up.
|
||||
\t\t *
|
||||
\t\t * Runs on every folder load — the first of the day, the one reopened
|
||||
\t\t * at launch, and any folder chosen after that — so the transport names
|
||||
\t\t * the folder on screen rather than sitting on a file from the last one
|
||||
\t\t * that is no longer in the table.
|
||||
\t\t */
|
||||
\t\tfunction primePlayerWithFirstRow() {
|
||||
\t\t\tvar first = state.filteredRows[ 0 ] || null;
|
||||
\t\t\tif ( ! first ) {
|
||||
\t\t\t\tstate.lastPlayed = null;
|
||||
\t\t\t\tplayerWrap.hidden = true;
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\t// The whole load, not a label: the file is read, the waveform drawn
|
||||
\t\t\t// and the channels listed, exactly as pressing Play would — and then
|
||||
\t\t\t// it sits there paused at the start instead of making a sound.
|
||||
\t\t\t//
|
||||
\t\t\t// Contained, because this is a convenience on top of opening a
|
||||
\t\t\t// folder and must never be able to stop one opening. A file that
|
||||
\t\t\t// won't load — a corrupt header, an engine that isn't answering —
|
||||
\t\t\t// leaves the table exactly as it is and the transport merely idle.
|
||||
\t\t\ttry {
|
||||
\t\t\t\tplayRow( first, true );
|
||||
\t\t\t} catch ( e ) {
|
||||
\t\t\t\tconsole.error( "BWF Analyser: the folder's first file could not be" +
|
||||
\t\t\t\t\t" loaded into the transport — " + ( ( e && e.message ) || e ) );
|
||||
\t\t\t\tstate.lastPlayed = { row: first, peaks: null, duration: 0 };
|
||||
\t\t\t\tplayerWrap.hidden = false;
|
||||
\t\t\t\tshowIdlePlayer( state.lastPlayed );
|
||||
\t\t\t}
|
||||
\t\t}
|
||||
|
||||
\t\tfunction setEditableMode( editable ) {""",
|
||||
),
|
||||
# The scan itself starts from nothing on the transport. Without this the
|
||||
# previous folder's file sits there, named, for as long as the new folder
|
||||
# takes to read — which on a card is long enough to look like the answer.
|
||||
(
|
||||
"""\t\t\tstate.rows = [];
|
||||
\t\t\tstopPlayback();
|
||||
\t\t\tstate.decodedBufferCache.clear();""",
|
||||
"""\t\t\tstate.rows = [];
|
||||
\t\t\tstopPlayback();
|
||||
\t\t\tstate.lastPlayed = null;
|
||||
\t\t\tstate.decodedBufferCache.clear();""",
|
||||
),
|
||||
(
|
||||
"""\t\t\t\tstate.rows = results;
|
||||
\t\t\t\tsetProgress( 0, 0 );
|
||||
\t\t\t\tsetStatus( format( t( "statusDone" ), results.length, skipped ) );
|
||||
\t\t\t\tapplyFilterAndSort();""",
|
||||
"""\t\t\t\tstate.rows = results;
|
||||
\t\t\t\tsetProgress( 0, 0 );
|
||||
\t\t\t\tsetStatus( format( t( "statusDone" ), results.length, skipped ) );
|
||||
\t\t\t\tapplyFilterAndSort();
|
||||
\t\t\t\tprimePlayerWithFirstRow();""",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
APP_JS_PATCHES = [
|
||||
# ---- what the run just rewrote is on the transport too --------------
|
||||
#
|
||||
# The run re-reads every file it touches, so the rows are current — but
|
||||
# the transport and the mixer drew their names when the file was loaded
|
||||
# and nothing tells them to look again. Rename the boom across a card
|
||||
# and the table says Boom Left while the mixer, showing the very file
|
||||
# that was rewritten, still says Boom.
|
||||
(
|
||||
"""\t\t\t\t\tsetStatus( format( t( "bulkEditDone" ), succeeded, failed ) );
|
||||
\t\t\t\t\trenderTableBody();""",
|
||||
"""\t\t\t\t\tsetStatus( format( t( "bulkEditDone" ), succeeded, failed ) );
|
||||
\t\t\t\t\trenderTableBody();
|
||||
\t\t\t\t\tvar onAir = state.playerRow && state.playerRow();
|
||||
\t\t\t\t\tif ( onAir ) {
|
||||
\t\t\t\t\t\tplayerFilename.textContent = onAir.parsed.fileName;
|
||||
\t\t\t\t\t\trenderChannelChips( onAir );
|
||||
\t\t\t\t\t}
|
||||
\t\t\t\t\trenderMixer();""",
|
||||
),
|
||||
# ---- a failed file has to say why -----------------------------------
|
||||
#
|
||||
# This swallowed the reason entirely, and a bulk run that writes nothing
|
||||
@@ -3031,6 +3551,8 @@ app_js = apply_patches(app_js, TRANSPORT_KEY_PATCHES, "transport key patch")
|
||||
app_js = apply_patches(app_js, APP_JS_PATCHES, "app js patch")
|
||||
app_js = apply_patches(app_js, PLAYER_PATCHES, "player patch")
|
||||
app_js = apply_patches(app_js, SPECTRO_PATCHES, "spectrogram patch")
|
||||
app_js = apply_patches(app_js, COLUMN_ORDER_PATCHES, "column order patch")
|
||||
app_js = apply_patches(app_js, FIRST_FILE_PATCHES, "first file patch")
|
||||
shim_js = guard(read(BUILD / "tauri-bridge.js"), "shim js", "script")
|
||||
|
||||
replacements = [
|
||||
|
||||
+117
-15
@@ -1038,21 +1038,10 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) {
|
||||
transform: translateY(0.15em);
|
||||
}
|
||||
|
||||
/* Save Image sits with the title, on the right, where the modal's own
|
||||
heading block already reserves the room. */
|
||||
.bwfa-scope .bwfa-spectro-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-spectro-head h4,
|
||||
.bwfa-scope .bwfa-spectro-head p {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* The layout of this block is further down, after the shared modal header
|
||||
rule that sets every dialog's heading to display: block — anything about
|
||||
it written here is overwritten by that. */
|
||||
.bwfa-scope .bwfa-spectro-save {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -1265,6 +1254,36 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ---- Save Image belongs on the file name's line --------------------
|
||||
The spectrogram's heading holds three things where the other dialogs
|
||||
hold two, and the rule above lays every heading out as a plain block —
|
||||
so the button, being inline, dropped to a line of its own under the
|
||||
name. Laid out as a wrapping flex row instead: the name and the button
|
||||
share the first line, the reading takes the second.
|
||||
|
||||
This has to sit after that shared rule rather than with the rest of the
|
||||
spectrogram's styling further up. An earlier attempt lived up there and
|
||||
was overwritten by it, which is why nothing moved.
|
||||
------------------------------------------------------------------ */
|
||||
.bwfa-scope .bwfa-spectro-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 var(--space-4);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-spectro-head h4 {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-spectro-head p {
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-spectro-save {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- 3. The body ---- */
|
||||
.bwfa-scope .modal-body,
|
||||
.bwfa-scope .bwfa-sheet-dialog .bwfa-export-fields,
|
||||
@@ -1412,11 +1431,27 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* A track name reads the same here as it does in the player's row: the same
|
||||
pill, the same size, the same colours, so the two places that list the
|
||||
tracks of a file look like the same thing.
|
||||
|
||||
It takes the look and not the behaviour — in the player the pill is the
|
||||
mute button, here it is a label and mute has its own M — so no pointer
|
||||
cursor and none of the muted/solo states. inline-block rather than the
|
||||
chip's inline-flex, because the name has to be able to ellipsis inside a
|
||||
9em column and text-overflow doesn't reach the children of a flex box. */
|
||||
.bwfa-scope .bwfa-mixer-name {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
padding: 0.3rem 0.5rem 0.3rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
background-color: var(--color-surface-alt);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-mixer-fader {
|
||||
@@ -1474,6 +1509,73 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) {
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
/* ---- Dragging a column heading ---------------------------------------
|
||||
The heading you are holding, following the pointer, so the gesture says
|
||||
what it is moving. Everything here is the app's own surface, border and
|
||||
text — the only new thing is the shadow, which is what lifts it off the
|
||||
table and tells you it is in your hand rather than in the row.
|
||||
|
||||
It exists only while a drag is in progress, and it never takes a click:
|
||||
the pointer has to reach the headings underneath to know where it is.
|
||||
------------------------------------------------------------------- */
|
||||
.bwfa-scope .bwfa-col-ghost {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
pointer-events: none;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* And the column it came from steps back while it is out of place. */
|
||||
.bwfa-scope .bwfa-table thead th.is-dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
|
||||
/* The waveform thumbnails sat on their own grey panel, which drew a row of
|
||||
boxes down the table and cut across the striping and the hover. The
|
||||
drawing itself only ever clears the canvas, so taking the fill off is
|
||||
enough for the row's own background to show through — the wave keeps its
|
||||
blue, and the column stops being a column of tiles. */
|
||||
.bwfa-scope .bwfa-waveform-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
/* ---- One button size --------------------------------------------------
|
||||
The transport's buttons set the standard, and the standard is what the
|
||||
framework calls .btn-sm. Everything else was a size larger, so a modal's
|
||||
Close and the toolbar's buttons stood a good 8px taller than the Play
|
||||
button right beneath them.
|
||||
|
||||
Done here, on .btn itself, rather than by adding .btn-sm to every button
|
||||
in the markup: buttons get built in JavaScript too, and a rule covers the
|
||||
ones written later without anybody having to remember. .btn-sm keeps
|
||||
working and is now simply the same thing.
|
||||
|
||||
Not included, and deliberately: .bwfa-round-btn, .modal-close and .chip
|
||||
are their own shapes and carry no .btn at all, so none of this reaches
|
||||
them.
|
||||
|
||||
This sits after the framework's own .btn-sm / .btn-lg / .btn-icon and
|
||||
matches their specificity, so it outranks all three. .btn-sm is now the
|
||||
same declaration twice over; .btn-lg and .btn-icon are unused, and a
|
||||
size modifier would have to be written to beat this rule to work again.
|
||||
The one button it does not size is the Choose beside the export
|
||||
destination, which takes its height from the field it stands next to.
|
||||
--------------------------------------------------------------------- */
|
||||
.bwfa-scope .btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
/* The word inside the transport button is not a target. It is rewritten as
|
||||
playback runs, and a node that changes under the pointer between mousedown
|
||||
and mouseup takes the click with it. */
|
||||
|
||||
+10
-9
@@ -78,10 +78,9 @@ body {
|
||||
background: var(--color-info-bg);
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry .btn {
|
||||
font-size: 15px;
|
||||
padding: 9px 20px;
|
||||
}
|
||||
/* Open Folder used to be sized up here, and the one on the launch screen
|
||||
sized up again. Both are gone: a button is a button, and the transport's
|
||||
size is the size. */
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry .bwfa-dropzone-subnote {
|
||||
max-width: 62ch;
|
||||
@@ -253,10 +252,6 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-edit-entry.is-folder-open .btn {
|
||||
font-size: var(--fs-sm);
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.bwfa-scope .bwfa-current-folder {
|
||||
font-size: 15px;
|
||||
@@ -576,10 +571,16 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The one button in the app that isn't the standard height, because it is
|
||||
not standing among buttons: it is paired with the path field beside it and
|
||||
takes that field's 38px, so the two read as one control. The row stretches
|
||||
its items, so this height only spells out what the field would impose
|
||||
anyway. Side padding matches every other button; the vertical padding has
|
||||
to be 0 for a fixed height to mean anything. */
|
||||
.bwfa-scope .bwfa-export-dest-row > .btn {
|
||||
flex: none;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
padding: 0 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
+499
-4
@@ -639,6 +639,14 @@ function press(el, key, opts) {
|
||||
await waitFor(() => /Done/i.test(status.textContent), "folder open + parse");
|
||||
|
||||
const rowsOf = () => Array.from(doc.querySelectorAll("[data-bwfa-table-body] tr"));
|
||||
|
||||
/** A row's cell for one column, found by key so it survives reordering. */
|
||||
const cellText = (tr, key) => {
|
||||
const heads = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"));
|
||||
const at = heads.findIndex((th) => th.getAttribute("data-bwfa-col") === key);
|
||||
assert(at !== -1, "no " + key + " column in the head");
|
||||
return tr.children[at].textContent.trim();
|
||||
};
|
||||
const headers = () => Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
|
||||
.map((th) => th.textContent.replace(/[▲▼\s]+$/, "").trim());
|
||||
const col = (rowIndex, header) => {
|
||||
@@ -654,10 +662,49 @@ function press(el, key, opts) {
|
||||
// appears on the first Play and vanishes when the file ends.
|
||||
const player = doc.querySelector("[data-bwfa-player]");
|
||||
assert.strictEqual(player.hidden, false, "the player is hidden with a folder open");
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, true,
|
||||
"Play is offered with nothing loaded");
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-channel-row]").hidden, true,
|
||||
"the channel hint is up with no channels to mute");
|
||||
// And it is already holding the folder's first file, ready to play:
|
||||
// a full table over a transport reading "nothing playing" was a step
|
||||
// nobody wanted to take before hearing the first take of the day.
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, false,
|
||||
"Play is dead, so the first file wasn't loaded");
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-player-filename]").textContent.trim(),
|
||||
cellText(rowsOf()[0], "fileName"),
|
||||
"the transport is holding something other than the first row");
|
||||
// Loaded means loaded: the channels are listed and mutable, and the
|
||||
// waveform is drawn, without a note being played.
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-channel-row]").hidden, false,
|
||||
"no channels to mute, so the file wasn't really loaded");
|
||||
assert(doc.querySelectorAll("[data-bwfa-channel-row] .bwfa-channel-chip").length > 0,
|
||||
"the channel row is empty");
|
||||
assert.notStrictEqual(
|
||||
doc.querySelector("[data-bwfa-player-duration]").textContent.trim(), "00:00:00",
|
||||
"no length, so the file was named but not read");
|
||||
// Precisely: a real transport, holding peaks, standing still at the
|
||||
// start. Paused rather than idle is what makes Play a resume.
|
||||
const held = window.BWFA_STATE.playback;
|
||||
assert(held, "there is no transport, only a label");
|
||||
assert(held.peaks && held.peaks.min && held.peaks.min.length,
|
||||
"loaded without the waveform data, so there is nothing to draw");
|
||||
assert.strictEqual(held.isPlaying, false, "it started playing on its own");
|
||||
assert.strictEqual(held.offset, 0, "it loaded part way in");
|
||||
});
|
||||
|
||||
await checkAsync("ready is not playing, and Play starts what it is holding", async () => {
|
||||
// Opening a folder should not make a noise on its own.
|
||||
assert.strictEqual(engine.state, null,
|
||||
"opening a folder started playback by itself");
|
||||
const playpause = doc.querySelector("[data-bwfa-player-playpause]");
|
||||
const named = doc.querySelector("[data-bwfa-player-filename]").textContent.trim();
|
||||
click(playpause);
|
||||
await waitFor(() => engine.state !== null, "Play to start the loaded file", 5000);
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-player-filename]").textContent.trim(),
|
||||
named, "Play started a different file from the one on the transport");
|
||||
click(playpause);
|
||||
await waitFor(() => /play/i.test(playpause.textContent), "it to come back to rest", 4000);
|
||||
// Put the stub back to untouched. The playback checks further down
|
||||
// wait for the engine to be asked for a file, and would sail past
|
||||
// that wait on the strength of this click rather than their own.
|
||||
engine.state = null;
|
||||
});
|
||||
|
||||
check("the folder opens straight into edit mode", () => {
|
||||
@@ -1280,6 +1327,57 @@ function press(el, key, opts) {
|
||||
});
|
||||
});
|
||||
|
||||
check("every button in the app is the one size", () => {
|
||||
// The transport's buttons set it. Everything used to be a size bigger
|
||||
// than the Play button beneath it, and a button added later inherits
|
||||
// whatever .btn says — so this reads the whole document rather than a
|
||||
// list someone has to remember to extend.
|
||||
//
|
||||
// Round buttons, the traffic-light close and the chips are out of
|
||||
// scope by construction: none of them carries .btn, so they cannot
|
||||
// appear here at all. Asserted below, because that is the assumption
|
||||
// this check rests on.
|
||||
const sizeOf = (b) => {
|
||||
const cs = window.getComputedStyle(b);
|
||||
return { font: cs.fontSize, pad: cs.padding, height: cs.height };
|
||||
};
|
||||
// Round ones are out, by shape rather than by name: the eject button
|
||||
// wears .btn as well as its own circle, so a list of exempt classes
|
||||
// would need maintaining and this does not.
|
||||
const round = (b) => /50%|9999px/.test(window.getComputedStyle(b).borderRadius || "");
|
||||
const buttons = Array.from(doc.querySelectorAll(".btn")).filter((b) => !round(b));
|
||||
assert(buttons.length > 20, "only " + buttons.length + " buttons found");
|
||||
assert(Array.from(doc.querySelectorAll(".btn")).some(round),
|
||||
"nothing round left in the sweep — the filter is no longer testing anything");
|
||||
// The Choose beside the export destination is the documented
|
||||
// exception: it takes the height of the path field it is paired with.
|
||||
const chooser = doc.querySelector("[data-bwfa-export-choose]");
|
||||
assert(chooser && chooser.classList.contains("btn"), "no export chooser");
|
||||
const standard = sizeOf(doc.querySelector("[data-bwfa-player-playpause]"));
|
||||
assert(standard.font && standard.pad,
|
||||
"couldn't read the transport button's own size");
|
||||
|
||||
const odd = buttons.filter((b) => b !== chooser)
|
||||
.filter((b) => {
|
||||
const s = sizeOf(b);
|
||||
return s.font !== standard.font || s.pad !== standard.pad;
|
||||
});
|
||||
assert.strictEqual(odd.length, 0, odd.length + " buttons are a different size, e.g. '" +
|
||||
(odd[0] && odd[0].className) + "' at " + JSON.stringify(odd[0] && sizeOf(odd[0])) +
|
||||
" against the transport's " + JSON.stringify(standard));
|
||||
|
||||
// The exception stays one exception, and stays the size of its field.
|
||||
assert.strictEqual(sizeOf(chooser).font, standard.font,
|
||||
"even the paired button shares the font size");
|
||||
assert.strictEqual(sizeOf(chooser).height, "38px",
|
||||
"the chooser no longer matches the field beside it: " + sizeOf(chooser).height);
|
||||
|
||||
["bwfa-round-btn", "modal-close", "chip"].forEach((cls) => {
|
||||
assert.strictEqual(doc.querySelectorAll("." + cls + ".btn").length, 0,
|
||||
"a ." + cls + " has picked up .btn, so this rule now resizes it");
|
||||
});
|
||||
});
|
||||
|
||||
check("the file the arrow leads to sits by the buttons, not mid-footer", () => {
|
||||
// jsdom has no layout, so this reads the cascade instead. The trap is
|
||||
// specific and it has already bitten once: the framework gives
|
||||
@@ -1408,6 +1506,26 @@ function press(el, key, opts) {
|
||||
missed.length + " of " + carrying.length +
|
||||
" files still say Boom: " + missed.join(", "));
|
||||
|
||||
// The transport is holding one of the files that was just rewritten,
|
||||
// and it drew its track names when the file was loaded. Nothing tells
|
||||
// it to look again, so it went on showing the old ones over a table
|
||||
// already showing the new.
|
||||
const onAir = window.BWFA_STATE.playerRow();
|
||||
assert(onAir, "nothing on the transport to check");
|
||||
const trackNames = ((onAir.parsed.ixml && onAir.parsed.ixml.trackList) || [])
|
||||
.map((t) => String(t.name || "").trim()).filter(Boolean);
|
||||
const chips = Array.from(doc.querySelectorAll("[data-bwfa-channel-row] .bwfa-channel-chip"))
|
||||
.map((c) => c.textContent.trim());
|
||||
trackNames.forEach((name) => assert(chips.indexOf(name) !== -1,
|
||||
"the player still lists the old names — has " + chips.join(", ") +
|
||||
", the file now says " + trackNames.join(", ")));
|
||||
const strips = Array.from(doc.querySelectorAll("[data-bwfa-mixer-strips] .bwfa-mixer-name"))
|
||||
.map((s) => s.textContent.trim());
|
||||
if (strips.length) {
|
||||
trackNames.forEach((name) => assert(strips.indexOf(name) !== -1,
|
||||
"the mixer still lists the old names: " + strips.join(", ")));
|
||||
}
|
||||
|
||||
// Applying closes the panel, and the checks below expect it open.
|
||||
click(doc.querySelector("[data-bwfa-bulk-edit-toggle]"));
|
||||
await waitFor(() => bulkPanel.hidden === false, "the panel to come back", 5000);
|
||||
@@ -1648,6 +1766,65 @@ function press(el, key, opts) {
|
||||
assert(/save/i.test(save.textContent), "it doesn't say what it does");
|
||||
});
|
||||
|
||||
check("the exported picture carries the file name and the frequency scale", () => {
|
||||
// A PNG leaves the app: on its own it is a pretty picture of an
|
||||
// unknown file at an unknown scale. Recorded rather than rendered —
|
||||
// there is no canvas here — so text that never reaches the image
|
||||
// cannot pass for a caption.
|
||||
const texts = [];
|
||||
const drawn = [];
|
||||
const realGetContext = window.HTMLCanvasElement.prototype.getContext;
|
||||
const realToBlob = window.HTMLCanvasElement.prototype.toBlob;
|
||||
const plot = doc.querySelector("[data-bwfa-spectro-canvas]");
|
||||
const plotWas = { width: plot.width, height: plot.height };
|
||||
let exported = null;
|
||||
|
||||
window.HTMLCanvasElement.prototype.getContext = function () {
|
||||
const base = realGetContext.call(this);
|
||||
return new Proxy(base, {
|
||||
get: (target, prop) => {
|
||||
if (prop === "fillText") return (s) => texts.push(String(s));
|
||||
if (prop === "drawImage") return (img) => drawn.push(img);
|
||||
return target[prop];
|
||||
},
|
||||
set: () => true,
|
||||
});
|
||||
};
|
||||
window.HTMLCanvasElement.prototype.toBlob = function () { exported = this; };
|
||||
try {
|
||||
click(doc.querySelector("[data-bwfa-spectro-save]"));
|
||||
} finally {
|
||||
window.HTMLCanvasElement.prototype.getContext = realGetContext;
|
||||
window.HTMLCanvasElement.prototype.toBlob = realToBlob;
|
||||
}
|
||||
|
||||
assert(exported, "nothing was handed to the save panel");
|
||||
assert.notStrictEqual(exported, plot,
|
||||
"it exported the bare plot, so the name and the scale are missing");
|
||||
assert(exported.width > plotWas.width,
|
||||
"the image is no wider than the plot, so there is no room for the scale");
|
||||
assert(drawn.indexOf(plot) !== -1, "the picture itself isn't in the export");
|
||||
|
||||
const said = texts.join(" | ");
|
||||
const named = doc.querySelector("[data-bwfa-spectro-title]").textContent.trim();
|
||||
assert(said.indexOf(named) !== -1, "the file isn't named in the image: " + said);
|
||||
assert(said.indexOf(window.BWFA_FOLDER_NAME) !== -1,
|
||||
"the folder isn't named, so a take number names nothing: " + said);
|
||||
assert(/\bkHz\b/.test(said), "no frequency scale in the image: " + said);
|
||||
// The top of the axis is Nyquist, and these are 48k files.
|
||||
assert(texts.some((s) => /^24(\.0)? kHz$/.test(s.trim())),
|
||||
"the axis doesn't reach 24 kHz on a 48k file: " + said);
|
||||
assert(texts.some((s) => /^0 Hz$/.test(s.trim())),
|
||||
"the axis doesn't start at DC: " + said);
|
||||
|
||||
// And the viewer is left exactly as it was: the caption belongs to
|
||||
// the export, not to the screen.
|
||||
assert.strictEqual(plot.width, plotWas.width, "the export resized the plot on screen");
|
||||
assert.strictEqual(plot.height, plotWas.height, "the export resized the plot on screen");
|
||||
assert.strictEqual(doc.querySelectorAll("[data-bwfa-spectro-scale] span").length, 5,
|
||||
"the on-screen scale was disturbed");
|
||||
});
|
||||
|
||||
check("it follows the channel chips rather than always summing", () => {
|
||||
// Solo the boom and you see the boom. Looking at what you are hearing
|
||||
// is the entire reason to have it in the player.
|
||||
@@ -1685,6 +1862,26 @@ function press(el, key, opts) {
|
||||
"the mixer and the player disagree on the track names: " + names.join(", "));
|
||||
});
|
||||
|
||||
check("a track name in the mixer looks like a track name in the player", () => {
|
||||
// The two places that list the tracks of a file should look like the
|
||||
// same thing. The mixer's name is a label and the player's is the
|
||||
// mute button, so it takes the look and not the behaviour.
|
||||
const chip = doc.querySelector("[data-bwfa-channel-row] .bwfa-channel-chip");
|
||||
const name = doc.querySelector("[data-bwfa-mixer-strips] .bwfa-mixer-name");
|
||||
assert(chip && name, "need both a player chip and a mixer name on screen");
|
||||
const asChip = window.getComputedStyle(chip);
|
||||
const asName = window.getComputedStyle(name);
|
||||
["fontSize", "padding", "borderRadius", "backgroundColor",
|
||||
"borderTopWidth", "borderTopStyle", "borderTopColor"].forEach((prop) => {
|
||||
assert.strictEqual(asName[prop], asChip[prop],
|
||||
prop + " differs — mixer has " + JSON.stringify(asName[prop]) +
|
||||
", player has " + JSON.stringify(asChip[prop]));
|
||||
});
|
||||
// Look, not behaviour: it is not offering itself as a button.
|
||||
assert.notStrictEqual(asName.cursor, "pointer",
|
||||
"the mixer's label looks clickable, and clicking it does nothing");
|
||||
});
|
||||
|
||||
await checkAsync("pulling a fader down reaches the engine, that track only", async () => {
|
||||
const fader = doc.querySelector('[data-bwfa-mixer-fader="0"]');
|
||||
assert(fader, "no fader on the first track");
|
||||
@@ -2032,6 +2229,292 @@ function press(el, key, opts) {
|
||||
"the stop", 5000);
|
||||
});
|
||||
|
||||
/* --- dragging a heading reorders the table --- */
|
||||
|
||||
// jsdom has no layout, so every getBoundingClientRect is a box of zeros
|
||||
// and the drag has nothing to aim at. Give the headings a synthetic
|
||||
// 100px each, on the prototype so it survives the re-render mid-drag,
|
||||
// and put it back afterwards — the mixer's click-to-seek measures itself
|
||||
// the same way and would read these boxes as its own.
|
||||
const realRect = window.Element.prototype.getBoundingClientRect;
|
||||
function withHeaderGeometry(fn) {
|
||||
window.Element.prototype.getBoundingClientRect = function () {
|
||||
if (this.tagName === "TH" && this.hasAttribute("data-bwfa-col")) {
|
||||
const at = Array.from(this.parentNode.children).indexOf(this);
|
||||
return { left: at * 100, right: at * 100 + 100, top: 0, bottom: 20,
|
||||
width: 100, height: 20, x: at * 100, y: 0 };
|
||||
}
|
||||
return realRect.call(this);
|
||||
};
|
||||
try { return fn(); } finally {
|
||||
window.Element.prototype.getBoundingClientRect = realRect;
|
||||
}
|
||||
}
|
||||
const headKeys = () => Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
|
||||
.map((th) => th.getAttribute("data-bwfa-col")).filter(Boolean);
|
||||
const settingsKeys = () => Array.from(
|
||||
doc.querySelectorAll("[data-bwfa-columns-menu] .form-switch span"))
|
||||
.map((s) => s.textContent.trim());
|
||||
const centreOf = (key) => {
|
||||
const heads = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"));
|
||||
const at = heads.findIndex((th) => th.getAttribute("data-bwfa-col") === key);
|
||||
return at * 100 + 50;
|
||||
};
|
||||
function dragHeading(key, ontoKey) {
|
||||
return withHeaderGeometry(() => {
|
||||
const th = doc.querySelector('[data-bwfa-col="' + key + '"]');
|
||||
assert(th, "no heading for " + key);
|
||||
const at = centreOf(key);
|
||||
th.dispatchEvent(new window.MouseEvent("mousedown",
|
||||
{ bubbles: true, clientX: at, button: 0 }));
|
||||
doc.dispatchEvent(new window.MouseEvent("mousemove",
|
||||
{ bubbles: true, clientX: centreOf(ontoKey) }));
|
||||
doc.dispatchEvent(new window.MouseEvent("mouseup", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
check("you can see what you are dragging, and only while you drag it", () => {
|
||||
const ghost = () => doc.querySelector("[data-bwfa-col-ghost]");
|
||||
assert(!ghost(), "something is following the pointer before a drag starts");
|
||||
withHeaderGeometry(() => {
|
||||
const key = headKeys()[1];
|
||||
const label = doc.querySelector('[data-bwfa-col="' + key + '"]')
|
||||
.firstChild.textContent.trim();
|
||||
doc.querySelector('[data-bwfa-col="' + key + '"]').dispatchEvent(
|
||||
new window.MouseEvent("mousedown", { bubbles: true, clientX: centreOf(key), button: 0 }));
|
||||
doc.dispatchEvent(new window.MouseEvent("mousemove",
|
||||
{ bubbles: true, clientX: centreOf(key) + 30, clientY: 40 }));
|
||||
|
||||
const held = ghost();
|
||||
assert(held, "nothing shows what is being dragged");
|
||||
assert.strictEqual(held.textContent.trim(), label,
|
||||
"it names " + held.textContent + " rather than the column being dragged");
|
||||
assert.strictEqual(held.getAttribute("data-bwfa-col-ghost"), key, "it names the wrong column");
|
||||
assert.strictEqual(window.getComputedStyle(held).pointerEvents, "none",
|
||||
"the marker takes the pointer, so the drag can't see the headings under it");
|
||||
// And the column it came from shows that it is the one in hand.
|
||||
assert(doc.querySelector('th[data-bwfa-col="' + key + '"]').classList.contains("is-dragging"),
|
||||
"the heading it came from isn't marked");
|
||||
|
||||
doc.dispatchEvent(new window.MouseEvent("mouseup", { bubbles: true }));
|
||||
assert(!ghost(), "it stayed on screen after the drag ended");
|
||||
assert.strictEqual(doc.querySelectorAll("th.is-dragging").length, 0,
|
||||
"a heading is left marked as being dragged");
|
||||
});
|
||||
});
|
||||
|
||||
check("dragging a heading moves the column, and the row follows it", () => {
|
||||
const before = headKeys();
|
||||
const moved = before[0], onto = before[3];
|
||||
const wasFirstCell = cellText(rowsOf()[0], moved);
|
||||
dragHeading(moved, onto);
|
||||
const after = headKeys();
|
||||
assert.notDeepStrictEqual(after, before, "nothing moved");
|
||||
assert.strictEqual(after.indexOf(moved), before.indexOf(onto),
|
||||
"expected " + moved + " to land where " + onto + " was; got " + after.join(", "));
|
||||
assert.deepStrictEqual(after.slice().sort(), before.slice().sort(),
|
||||
"a column was lost or duplicated: " + after.join(", "));
|
||||
// The body has to move with the head, or every value is under the
|
||||
// wrong heading — which is worse than not reordering at all.
|
||||
assert.strictEqual(cellText(rowsOf()[0], moved), wasFirstCell,
|
||||
"the cells didn't follow their heading");
|
||||
});
|
||||
|
||||
await checkAsync("a drag is not also a request to sort", async () => {
|
||||
// mousedown-move-mouseup on a heading ends in a click, and the click
|
||||
// is the one the sort listens for. Reordering the table and resorting
|
||||
// it in the same gesture is two surprises for the price of one.
|
||||
const key = headKeys()[1];
|
||||
const sortedBy = () => {
|
||||
const th = doc.querySelector("[data-bwfa-table-head] th.is-sorted");
|
||||
return th && th.getAttribute("data-bwfa-sort");
|
||||
};
|
||||
const before = sortedBy();
|
||||
const arrowOf = () => {
|
||||
const el = doc.querySelector('[data-bwfa-sort="' + before + '"] .bwfa-sort-indicator');
|
||||
return el && el.textContent;
|
||||
};
|
||||
const startedAt = arrowOf();
|
||||
try {
|
||||
dragHeading(key, headKeys()[3]);
|
||||
// The click the browser sends after a drag, which is what gets past
|
||||
// a naive implementation.
|
||||
doc.querySelector('[data-bwfa-col="' + key + '"]')
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
assert.strictEqual(sortedBy(), before,
|
||||
"the drag re-sorted the table by " + sortedBy());
|
||||
|
||||
// And a plain click still sorts, or this could be "fixed" by
|
||||
// breaking sorting altogether. Done on the column that is already
|
||||
// sorted, and toggled back: the checks further down read the first
|
||||
// row of the table and would be sorting a different day's work.
|
||||
assert(before, "nothing is sorted, so there is no direction to flip");
|
||||
// The suppression lifts on the next tick, which is the tick after the
|
||||
// browser has delivered the drag's own click. Wait for it, or this
|
||||
// would be testing the suppression a second time over.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const arrow = () => doc.querySelector(
|
||||
'[data-bwfa-sort="' + before + '"] .bwfa-sort-indicator').textContent;
|
||||
const plainClick = () => doc.querySelector('[data-bwfa-sort="' + before + '"]')
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const wasArrow = arrow();
|
||||
plainClick();
|
||||
assert.notStrictEqual(arrow(), wasArrow, "a plain click stopped sorting");
|
||||
plainClick();
|
||||
assert.strictEqual(arrow(), wasArrow, "couldn't put the sort back as it was");
|
||||
assert.strictEqual(sortedBy(), before, "the sort column moved");
|
||||
} finally {
|
||||
// Whatever happened above, hand the table back sorted the way it
|
||||
// was found. A broken suppression re-sorts it, and the checks
|
||||
// further down read the first row — they should report their own
|
||||
// problem, not this one, and not by hanging.
|
||||
for (let tries = 0; tries < 4; tries++) {
|
||||
if (sortedBy() === before && arrowOf() === startedAt) break;
|
||||
doc.querySelector('[data-bwfa-sort="' + before + '"]')
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
check("the waveform column takes the table's own background", () => {
|
||||
const css = fs.readFileSync(INDEX, "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\n/g, " ");
|
||||
const rules = css.match(/\.bwfa-scope \.bwfa-waveform-thumb \{[^}]*\}/g) || [];
|
||||
assert(rules.length >= 1, "no rule for the waveform thumbnails");
|
||||
// The last one wins, and it has to hand the background back.
|
||||
const last = rules[rules.length - 1];
|
||||
assert(/background-color:\s*transparent/.test(last),
|
||||
"the thumbnails still paint their own panel: " + last);
|
||||
});
|
||||
|
||||
check("the order is written down, so it comes back next launch", () => {
|
||||
const saved = JSON.parse(window.localStorage.getItem("bwfa_column_order_v1"));
|
||||
assert(Array.isArray(saved), "nothing was saved");
|
||||
assert.deepStrictEqual(saved.filter((k) => headKeys().indexOf(k) !== -1), headKeys(),
|
||||
"what was saved isn't the order on screen");
|
||||
// Hidden columns are in there too, or switching one back on would
|
||||
// send it to the end of the table.
|
||||
assert(saved.length > headKeys().length,
|
||||
"the hidden columns were dropped from the saved order");
|
||||
});
|
||||
|
||||
check("the settings list is in the same order as the table", () => {
|
||||
click(doc.querySelector("[data-bwfa-columns-open]"));
|
||||
const labels = settingsKeys();
|
||||
const heads = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
|
||||
.filter((th) => th.getAttribute("data-bwfa-col"))
|
||||
.map((th) => th.firstChild.textContent.trim());
|
||||
// The list carries hidden columns as well, so the visible headings
|
||||
// should appear within it in exactly this order.
|
||||
const positions = heads.map((label) => labels.indexOf(label));
|
||||
assert(positions.every((p) => p !== -1),
|
||||
"a heading is missing from the settings list: " + heads.join(", "));
|
||||
assert.deepStrictEqual(positions.slice().sort((a, b) => a - b), positions,
|
||||
"settings lists them in a different order from the table: " + labels.join(", "));
|
||||
click(doc.querySelector("[data-bwfa-columns-close]"));
|
||||
});
|
||||
|
||||
check("a hidden column keeps its place rather than going to the end", () => {
|
||||
const order = () => JSON.parse(window.localStorage.getItem("bwfa_column_order_v1"));
|
||||
const hiding = headKeys()[2];
|
||||
const neighbour = order()[order().indexOf(hiding) - 1];
|
||||
click(doc.querySelector("[data-bwfa-columns-open]"));
|
||||
const toggles = Array.from(doc.querySelectorAll("[data-bwfa-columns-menu] .form-switch"));
|
||||
const label = doc.querySelector('[data-bwfa-col="' + hiding + '"]').firstChild.textContent.trim();
|
||||
const row = toggles.filter((r) => r.querySelector("span").textContent.trim() === label)[0];
|
||||
assert(row, "no toggle for " + label);
|
||||
const box = row.querySelector("input");
|
||||
box.checked = false;
|
||||
box.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
assert.strictEqual(headKeys().indexOf(hiding), -1, "it stayed in the table");
|
||||
// Still in the order, still next to what it was next to.
|
||||
assert.strictEqual(order()[order().indexOf(hiding) - 1], neighbour,
|
||||
"hiding it moved it in the order");
|
||||
box.checked = true;
|
||||
box.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
assert.strictEqual(order()[order().indexOf(hiding) - 1], neighbour,
|
||||
"it came back somewhere else");
|
||||
click(doc.querySelector("[data-bwfa-columns-close]"));
|
||||
});
|
||||
|
||||
check("dragging over a hidden column doesn't disturb it", () => {
|
||||
// The drag only ever sees what is on screen, but the order it edits
|
||||
// holds everything — so a hidden column has to keep its neighbour.
|
||||
const order = () => JSON.parse(window.localStorage.getItem("bwfa_column_order_v1"));
|
||||
click(doc.querySelector("[data-bwfa-columns-open]"));
|
||||
const label = doc.querySelector('[data-bwfa-col="folder"]').firstChild.textContent.trim();
|
||||
const row = Array.from(doc.querySelectorAll("[data-bwfa-columns-menu] .form-switch"))
|
||||
.filter((r) => r.querySelector("span").textContent.trim() === label)[0];
|
||||
const box = row.querySelector("input");
|
||||
box.checked = false;
|
||||
box.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
click(doc.querySelector("[data-bwfa-columns-close]"));
|
||||
|
||||
const before = order();
|
||||
const visible = headKeys();
|
||||
dragHeading(visible[0], visible[2]);
|
||||
const after = order();
|
||||
assert.deepStrictEqual(after.slice().sort(), before.slice().sort(),
|
||||
"the hidden column was lost in the drag");
|
||||
assert(after.indexOf("folder") !== -1, "the hidden column fell out of the order");
|
||||
});
|
||||
|
||||
check("neither settings nor the mixer has a second way out", () => {
|
||||
// Settings saves as you touch it and the mixer applies as you move a
|
||||
// fader, so a Done or a Close at the bottom implied there was
|
||||
// something waiting to be confirmed. The window's own close remains,
|
||||
// and is now the only one.
|
||||
const settings = doc.querySelector("[data-bwfa-columns-modal]");
|
||||
assert(settings, "no settings dialog");
|
||||
assert(!settings.querySelector(".modal-footer"),
|
||||
"settings still carries a footer for a button to sit in");
|
||||
const settingsOuts = settings.querySelectorAll("[data-bwfa-columns-close]");
|
||||
assert.strictEqual(settingsOuts.length, 1,
|
||||
"settings offers " + settingsOuts.length + " ways out");
|
||||
assert(settingsOuts[0].classList.contains("modal-close"),
|
||||
"the one way out of settings isn't the window close");
|
||||
|
||||
const mixer = doc.querySelector("[data-bwfa-mixer]");
|
||||
const mixerOuts = mixer.querySelectorAll("[data-bwfa-mixer-close]");
|
||||
assert.strictEqual(mixerOuts.length, 1,
|
||||
"the mixer offers " + mixerOuts.length + " ways out");
|
||||
assert(mixerOuts[0].classList.contains("modal-close"),
|
||||
"the mixer's one way out isn't the window close");
|
||||
// Its footer stays, because stepping between files lives there.
|
||||
assert(mixer.querySelector(".modal-footer [data-bwfa-mixer-next]"),
|
||||
"stepping to the next file went with the button");
|
||||
});
|
||||
|
||||
check("Save Image shares the file name's line", () => {
|
||||
// jsdom has no layout, so this reads the cascade and the order. The
|
||||
// button used to share a flex row with the reading underneath, and
|
||||
// that row carries the gap below the whole heading block — so it
|
||||
// aligned to the bottom of the reading and sat under the name.
|
||||
const css = fs.readFileSync(INDEX, "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\n/g, " ");
|
||||
// The last rule for each selector, because that is the one that wins
|
||||
// and the plugin styles this block too.
|
||||
const lastRule = (selector) => {
|
||||
const all = css.match(new RegExp(selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
|
||||
" \\{[^}]*\\}", "g")) || [];
|
||||
return all[all.length - 1];
|
||||
};
|
||||
const head = lastRule(".bwfa-scope .bwfa-spectro-head");
|
||||
assert(head && /flex-wrap:\s*wrap/.test(head),
|
||||
"the heading block can't wrap, so the three parts share one line: " + head);
|
||||
const note = lastRule(".bwfa-scope .bwfa-spectro-head p");
|
||||
assert(note && /flex:\s*1 0 100%/.test(note),
|
||||
"the reading doesn't take a line of its own: " + note);
|
||||
// And it has to come after the button in the markup, or the button is
|
||||
// what wraps to the second line instead.
|
||||
const kids = Array.from(doc.querySelector(".bwfa-spectro-head").children);
|
||||
const saveAt = kids.findIndex((k) => k.hasAttribute("data-bwfa-spectro-save"));
|
||||
const noteAt = kids.findIndex((k) => k.hasAttribute("data-bwfa-spectro-note"));
|
||||
assert(saveAt !== -1 && noteAt !== -1, "the heading block is missing a part");
|
||||
assert(saveAt < noteAt,
|
||||
"the button comes after the reading, so it wraps below the name");
|
||||
});
|
||||
|
||||
check("the settings are folded away behind their own headings", () => {
|
||||
click(doc.querySelector("[data-bwfa-columns-open]"));
|
||||
const sections = Array.from(doc.querySelectorAll("[data-bwfa-settings]"));
|
||||
@@ -2356,6 +2839,18 @@ function press(el, key, opts) {
|
||||
"drop highlight not cleared");
|
||||
});
|
||||
|
||||
check("a new folder puts its own first file on the transport", () => {
|
||||
// The one that bites: a folder had been played, then another folder
|
||||
// was opened, and the transport went on naming a file that is no
|
||||
// longer anywhere in the table.
|
||||
const named = doc.querySelector("[data-bwfa-player-filename]").textContent.trim();
|
||||
assert.strictEqual(named, cellText(rowsOf()[0], "fileName"),
|
||||
"the transport is still holding the previous folder's file: " + named);
|
||||
assert(/A003_14B_T3/.test(named), "expected the dropped folder's own file, got " + named);
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, false,
|
||||
"the new folder's first file isn't ready to play");
|
||||
});
|
||||
|
||||
/* --- an empty folder clears the table rather than lying about it --- */
|
||||
|
||||
const emptyFolder = path.join(root, "Empty Card");
|
||||
|
||||
Reference in New Issue
Block a user