From 45c37a5e7355a277864d60f27cca5149b603feb0 Mon Sep 17 00:00:00 2001 From: Kawa Date: Fri, 14 Aug 2026 09:19:36 +0200 Subject: [PATCH] feat: resizable side panels, audio layer fixes - Add horizontal resize handles for browser and properties panels with localStorage persistence - Support hide/show toggles for each side panel - Fix audio layers not being interactive on canvas (hasControls, hasBorders, evented flags) - Convert hardcoded panel widths to CSS variables for responsive layout - Support narrower windows by shrinking panels while preserving minimum canvas width - Ensure old projects with audio layers don't show phantom control boxes at 0,0 Co-Authored-By: Claude Haiku 4.5 --- src/index.html | 6 ++ src/js/database.js | 11 +++ src/js/functions.js | 211 ++++++++++++++++++++++++++++++++++++++++++++ src/js/ui.js | 19 ++-- src/styles.css | 165 +++++++++++++++++++++++++++++----- 5 files changed, 383 insertions(+), 29 deletions(-) diff --git a/src/index.html b/src/index.html index 2b91062..0c09505 100644 --- a/src/index.html +++ b/src/index.html @@ -311,6 +311,12 @@ +
+
+
+
+
+
diff --git a/src/js/database.js b/src/js/database.js index fb11c6a..c658067 100644 --- a/src/js/database.js +++ b/src/js/database.js @@ -53,6 +53,8 @@ function checkDB() { 'cornerRadius', 'selectable', 'hasControls', + 'hasBorders', + 'evented', 'subTargetCheck', 'id', 'hoverCursor', @@ -230,6 +232,8 @@ async function autoSave() { 'cornerRadius', 'selectable', 'hasControls', + 'hasBorders', + 'evented', 'subTargetCheck', 'id', 'hoverCursor', @@ -348,6 +352,13 @@ function loadProject() { }); replaceSource(canvas.getItemById(object.id), canvas); } else { + // Projects saved before audio layers were flagged still carry + // controls / borders, which show up as a phantom box at 0,0. + canvas.getItemById(object.id).set({ + hasControls: false, + hasBorders: false, + evented: false, + }); renderProp('volume', canvas.getItemById(object.id)); } }); diff --git a/src/js/functions.js b/src/js/functions.js index 79999d1..3c0fd2b 100644 --- a/src/js/functions.js +++ b/src/js/functions.js @@ -4381,6 +4381,11 @@ function newAudioLayer(src) { duration: audio.duration * 1000, opacity: 0, selectable: false, + // Audio layers are invisible placeholders. Selecting them (from the + // layer list) must not paint controls / a bounding box on the canvas. + hasControls: false, + hasBorders: false, + evented: false, volume: 0.5, assetType: 'audio', shadow: { @@ -5367,6 +5372,10 @@ function resetHeight() { 'calc(100% - ' + (top + 97) + 'px)' ); $('#properties').css('height', 'calc(100% - ' + (top + 97) + 'px)'); + $('.panel-handle').css( + 'height', + 'calc(100% - ' + (top + 97) + 'px)' + ); $('#timeline-handle').css('bottom', top + 95); resizeCanvas(); } @@ -5399,6 +5408,208 @@ $(document).on('pointerdown', '#timeline-handle', dragTimeline); oldtimelinepos = $(window).height() - 92 - $('#timearea').height(); +// Side panels: horizontal resize and hide/show +// The widths live as CSS variables on :root - every panel rule is a calc() of +// them, so moving a handle only has to write one number. +const RAIL_WIDTH = 76; +const BROWSER_MIN_WIDTH = 210; +const BROWSER_MAX_WIDTH = 640; +const PROPS_MIN_WIDTH = 300; +const PROPS_MAX_WIDTH = 640; +// Canvas width kept free while dragging, whatever the window size +const CANVAS_MIN_WIDTH = 240; +const PANEL_LAYOUT_KEY = 'motionity-panel-layout'; + +var browserwidth = 299; +var propswidth = 300; +var propshidden = false; +// Tool to reopen the library with, set when it gets collapsed +var lasttool = $('.tool-active').attr('id') || 'shape-tool'; + +function clampWidth(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function browserHidden() { + return $('#browser').hasClass('collapsed'); +} + +function applyPanelWidths() { + const root = document.documentElement.style; + const hidden = browserHidden(); + var browser = hidden ? 0 : browserwidth; + var properties = propshidden ? 0 : propswidth; + // Shrink to fit rather than squeeze the canvas out of a narrow window. The + // stored widths are left alone, so the panels grow back on a wider one. + var over = + RAIL_WIDTH + + browser + + properties + + CANVAS_MIN_WIDTH - + $(window).width(); + if (over > 0 && properties > PROPS_MIN_WIDTH) { + const cut = Math.min(over, properties - PROPS_MIN_WIDTH); + properties -= cut; + over -= cut; + } + if (over > 0 && browser > BROWSER_MIN_WIDTH) { + browser -= Math.min(over, browser - BROWSER_MIN_WIDTH); + } + root.setProperty('--browser-w', browser + 'px'); + root.setProperty('--props-w', properties + 'px'); + // The layer list and the timeline keep their column width while the library + // is hidden, otherwise layer names would get squashed into the tool rail. + if (!hidden) { + root.setProperty('--layers-w', RAIL_WIDTH + browser + 'px'); + } + $('#browser-handle').toggleClass('panel-hidden', hidden); + $('#properties-handle').toggleClass('panel-hidden', propshidden); + $('#properties').toggleClass('collapsed', propshidden); + $('#browser-toggle').attr( + 'title', + hidden ? 'Show the library' : 'Hide the library' + ); + $('#properties-toggle').attr( + 'title', + propshidden ? 'Show the properties' : 'Hide the properties' + ); +} + +function savePanelLayout() { + try { + localStorage.setItem( + PANEL_LAYOUT_KEY, + JSON.stringify({ + browser: browserwidth, + properties: propswidth, + propertieshidden: propshidden, + }) + ); + } catch (err) { + // Private browsing or a full quota - the layout just won't persist + } +} + +function restorePanelLayout() { + var stored; + try { + stored = JSON.parse(localStorage.getItem(PANEL_LAYOUT_KEY)); + } catch (err) { + stored = null; + } + if (stored) { + if (typeof stored.browser == 'number') { + browserwidth = clampWidth( + stored.browser, + BROWSER_MIN_WIDTH, + BROWSER_MAX_WIDTH + ); + } + if (typeof stored.properties == 'number') { + propswidth = clampWidth( + stored.properties, + PROPS_MIN_WIDTH, + PROPS_MAX_WIDTH + ); + } + propshidden = stored.propertieshidden === true; + } + applyPanelWidths(); +} + +// Dragging a side panel border +function dragPanel(e) { + if (e.which == 3 || modalOpen() || $(this).hasClass('panel-hidden')) { + return false; + } + e.preventDefault(); + const handle = $(this); + const isbrowser = handle.attr('id') == 'browser-handle'; + const startx = e.pageX; + const startwidth = isbrowser ? browserwidth : propswidth; + // Suppress text selection for the duration of the drag only + const previousSelectStart = document.onselectstart; + document.onselectstart = function () { + return false; + }; + handle.addClass('handle-dragging'); + + function draggingPanelBorder(ev) { + // The properties panel is anchored to the right, so its width grows the + // other way round + const delta = isbrowser ? ev.pageX - startx : startx - ev.pageX; + const otherwidth = isbrowser + ? propshidden + ? 0 + : propswidth + : browserHidden() + ? 0 + : browserwidth; + const room = + $(window).width() - RAIL_WIDTH - CANVAS_MIN_WIDTH - otherwidth; + if (isbrowser) { + browserwidth = clampWidth( + startwidth + delta, + BROWSER_MIN_WIDTH, + Math.min(BROWSER_MAX_WIDTH, room) + ); + } else { + propswidth = clampWidth( + startwidth + delta, + PROPS_MIN_WIDTH, + Math.min(PROPS_MAX_WIDTH, room) + ); + } + applyPanelWidths(); + resizeCanvas(); + } + function releasedPanelBorder() { + document.onselectstart = previousSelectStart || null; + handle.removeClass('handle-dragging'); + savePanelLayout(); + } + bindPointerDrag(e, this, draggingPanelBorder, releasedPanelBorder); +} +$(document).on('pointerdown', '.panel-handle', dragPanel); + +// Keep a click on a hide/show button from starting a resize drag +$(document).on('pointerdown', '.panel-toggle', function (e) { + e.stopPropagation(); +}); + +$(document).on('click', '#browser-toggle', function (e) { + e.stopPropagation(); + if (browserHidden()) { + // Reopening goes through the tool click so the rail icons stay in sync + $('#' + lasttool).trigger('click'); + } else { + collapsePanel(); + } +}); + +$(document).on('click', '#properties-toggle', function (e) { + e.stopPropagation(); + propshidden = !propshidden; + applyPanelWidths(); + savePanelLayout(); + resizeCanvas(); +}); + +// Re-fits the panels after the window itself changed size. Runs after the +// resizeCanvas listener registered at the top of this file, so the canvas is +// measured again once the new widths are in. +window.addEventListener( + 'resize', + function () { + applyPanelWidths(); + resizeCanvas(); + }, + false +); + +restorePanelLayout(); +resizeCanvas(); + // Sync scrolling (vertical) function syncScroll(el1, el2) { var $el1 = $(el1); diff --git a/src/js/ui.js b/src/js/ui.js index 546882e..e8e2da7 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -1219,12 +1219,13 @@ $(document).on( // Switch tool function switchTool(e) { - $('#browser').removeClass('collapsed'); - $('#canvas-area').removeClass('canvas-full'); if ($(this).attr('id') == 'more-tool') { showMore(); return false; } + $('#browser').removeClass('collapsed'); + $('#behind-browser').removeClass('collapsed'); + applyPanelWidths(); resizeCanvas(); var act = $('.tool-active'); if (act.attr('id') == 'image-tool') { @@ -1292,6 +1293,10 @@ function dragObject(e) { if (e.which == 3) { return false; } + // Measure before the clone leaves the panel - panel items are sized in + // percentages of a resizable panel, so a clone parented to would + // report the body width instead. + var sourcewidth = $(this).width(); var drag = $(this).clone(); drag.css({ background: 'transparent', @@ -1304,7 +1309,7 @@ function dragObject(e) { zIndex: 9999999, left: $(this).offset().left, top: $(this).offset().top, - width: canvas.getZoom() * drag.width(), + width: canvas.getZoom() * sourcewidth, pointerEvents: 'none', opacity: 0, }); @@ -1619,10 +1624,13 @@ $(document).on( // Collapse library function collapsePanel() { + var act = $('.tool-active'); + if (act.length > 0) { + // Remembered so the handle button can reopen the same tab + lasttool = act.attr('id'); + } $('#browser').addClass('collapsed'); $('#behind-browser').addClass('collapsed'); - $('#canvas-area').addClass('canvas-full'); - var act = $('.tool-active'); if (act.attr('id') == 'image-tool') { act.find('img').attr('src', 'assets/image.svg'); } else if (act.attr('id') == 'text-tool') { @@ -1637,6 +1645,7 @@ function collapsePanel() { act.find('img').attr('src', 'assets/uploads.svg'); } $('.tool-active').removeClass('tool-active'); + applyPanelWidths(); resizeCanvas(); } $(document).on('click', '#collapse', collapsePanel); diff --git a/src/styles.css b/src/styles.css index 776a225..bf79c05 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7,6 +7,14 @@ --input-color: #22233e; --accent-color: #166ef1; --button-hover: #262746; + /* Panel geometry. Every rule that used to hardcode 76/299/300/375 derives + from these so the resize handles only have to move one number each. + --layers-w tracks --rail-w + --browser-w while the library panel is + visible, but stays put when it is hidden so the layer names keep room. */ + --rail-w: 76px; + --browser-w: 299px; + --props-w: 300px; + --layers-w: 375px; } /* :root { @@ -41,7 +49,7 @@ body { font-size: 14px; font-weight: 600; background: var(--panel-back); - width: 76px; + width: var(--rail-w); border-right: 1px solid var(--panel-stroke); box-sizing: border-box; } @@ -244,7 +252,7 @@ body { #toolbar { position: absolute; height: calc(100% - 340px); - width: 76px; + width: var(--rail-w); background-color: var(--panel-back); border-right: 1px solid var(--panel-stroke); left: 0px; @@ -308,8 +316,8 @@ body { #behind-browser { position: absolute; height: 100%; - width: 299px; - left: 76px; + width: var(--browser-w); + left: var(--rail-w); background: var(--panel-back); border-right: 1px solid var(--panel-stroke); z-index: 1; @@ -318,10 +326,10 @@ body { #browser { position: absolute; height: calc(100% - 450px); - width: 299px; + width: var(--browser-w); background-color: var(--panel-back); border-right: 1px solid var(--panel-stroke); - left: 76px; + left: var(--rail-w); top: 110px; box-sizing: border-box; z-index: 999999; @@ -333,7 +341,7 @@ body { display: none !important; } #browser-container { - width: 260px; + width: calc(var(--browser-w) - 39px); margin-left: auto; margin-right: auto; height: 100%; @@ -359,7 +367,7 @@ body { } .image-grid-item, .video-grid-item { - width: 120px; + width: 100%; margin-bottom: 15px; position: relative; } @@ -388,7 +396,7 @@ body { text-align: right; text-decoration: none; text-shadow: 0px 1px 5px #000000; - width: 110px; + width: calc(100% - 10px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -436,7 +444,9 @@ body { grid-gap: 15px; width: 100%; grid-auto-columns: auto; - grid-template-columns: 52px 52px 52px 52px; + /* Column count follows the panel width so the grid reflows when the library + is resized instead of overflowing a fixed four-column track. */ + grid-template-columns: repeat(auto-fill, minmax(52px, 1fr)); overflow: hidden; } .scroll-row:before { @@ -461,7 +471,7 @@ body { #search-fixed { position: fixed; top: 0px; - width: 279px; + width: calc(var(--browser-w) - 20px); padding-left: 19px; margin-left: -19px; z-index: 999999; @@ -776,12 +786,13 @@ body { position: absolute; right: 0px; top: 0px; - width: 300px; + width: var(--props-w); height: calc(100% - 340px); background-color: var(--panel-back); border-left: 1px solid var(--panel-stroke); z-index: 999; overflow-y: overlay; + overflow-x: hidden; } #properties-overlay { width: 100%; @@ -830,9 +841,12 @@ hr { background-color: var(--panel-stroke); margin-bottom: 20px; } +#properties hr { + width: calc(var(--props-w) - 40px); +} /* Property sections */ .panel-section { - width: 260px; + width: calc(var(--props-w) - 40px); margin-left: auto; margin-right: auto; } @@ -869,8 +883,17 @@ th { width: 189px; display: flex; align-items: center; + /* Keeps the inputs against the right edge once the column can be wider + than its contents */ + justify-content: flex-end; margin-left: auto; } +/* Inside the properties panel the value column follows the panel width, so + dropdowns and sliders grow when the panel is widened. 111px is the label + column plus the panel gutters. The filters popup keeps the fixed width. */ +#properties .value-col { + width: calc(var(--props-w) - 111px); +} /* Dropdows */ .nice-select, .list, @@ -1550,16 +1573,12 @@ label span { #canvas-area { position: absolute; top: 0px; - left: 375px; + left: calc(var(--rail-w) + var(--browser-w)); height: calc(100% - 342px); - width: calc(100% - 675px); + width: calc(100% - var(--rail-w) - var(--browser-w) - var(--props-w)); box-sizing: border-box; overflow: hidden; } -.canvas-full { - left: 76px !important; - width: calc(100% - 376px) !important; -} .canvas-container { left: 0px; top: 0px; @@ -1614,7 +1633,7 @@ canvas { #layer-list { position: absolute; height: 245px; - width: 375px; + width: var(--layers-w); left: 0px; bottom: 60px; background-color: var(--panel-back); @@ -1816,7 +1835,7 @@ layer:nth-child(even) .properties { color: var(--secondary-text-color); line-height: 40px; height: 40px; - width: 375px; + width: var(--layers-w); position: fixed; margin-top: -35px; background-color: var(--panel-back); @@ -1840,10 +1859,10 @@ layer:nth-child(even) .properties { #timearea { position: absolute; bottom: 60px; - left: 375px; + left: var(--layers-w); height: 245px; background-color: var(--main-back); - width: calc(100% - 375px); + width: calc(100% - var(--layers-w)); border-top: 1px solid var(--panel-stroke); z-index: 999; } @@ -1858,10 +1877,108 @@ layer:nth-child(even) .properties { cursor: ns-resize; background-color: var(--panel-stroke); } +/* Side panel resize handles. Each one straddles the border between a side + panel and the canvas, and carries the hide/show button for that panel. + Their height is kept in sync with the panels by resetHeight(). */ +.panel-handle { + position: absolute; + top: 0px; + height: calc(100% - 340px); + width: 7px; + z-index: 9999999; + box-sizing: border-box; + touch-action: none; + -webkit-user-drag: none; + -webkit-user-select: none; + user-select: none; +} +#browser-handle { + left: calc(var(--rail-w) + var(--browser-w) - 3px); +} +#properties-handle { + right: calc(var(--props-w) - 3px); +} +#properties-handle.panel-hidden { + right: 0px; +} +.panel-handle:not(.panel-hidden):hover, +.panel-handle.handle-dragging { + cursor: ew-resize; +} +.panel-handle:before { + content: ""; + position: absolute; + left: 2px; + top: 0px; + width: 2px; + height: 100%; + background-color: var(--accent-color); + opacity: 0; +} +.panel-handle:not(.panel-hidden):hover:before, +.panel-handle.handle-dragging:before { + opacity: 1; +} +.panel-toggle { + position: absolute; + top: 50%; + margin-top: -18px; + width: 18px; + height: 36px; + background-color: var(--input-color); + border: 1px solid var(--panel-stroke); + box-sizing: border-box; + /* Always on screen, dimmed. Revealing it on hover meant having to find the + 3px resize line first, then travel onto the button. */ + opacity: 0.4; +} +.panel-toggle:hover { + cursor: pointer; + background-color: var(--button-hover); +} +.panel-toggle:hover, +.panel-handle:hover .panel-toggle, +.panel-handle.handle-dragging .panel-toggle, +.panel-handle.panel-hidden .panel-toggle { + opacity: 1; +} +/* Chevron, pointing at the edge the panel would collapse towards */ +.panel-toggle:after { + content: ""; + position: absolute; + width: 5px; + height: 5px; + top: 50%; + left: 50%; + border-left: 1.5px solid var(--secondary-text-color); + border-bottom: 1.5px solid var(--secondary-text-color); +} +#browser-handle .panel-toggle { + left: 5px; + border-radius: 0px 4px 4px 0px; +} +#properties-handle .panel-toggle { + right: 5px; + border-radius: 4px 0px 0px 4px; +} +#properties-handle.panel-hidden .panel-toggle { + right: 4px; +} +#browser-handle .panel-toggle:after, +#properties-handle.panel-hidden .panel-toggle:after { + margin: -4px 0px 0px -2px; + transform: rotate(45deg); +} +#properties-handle .panel-toggle:after, +#browser-handle.panel-hidden .panel-toggle:after { + margin: -4px 0px 0px -5px; + transform: rotate(-135deg); +} #seekarea { height: 100%; z-index: 99999; - width: calc(100% - 375px); + /* Fixed positioning - the percentage is the viewport, not #timearea. */ + width: calc(100% - var(--layers-w)); overflow-x: auto; overflow-y: hidden; position: fixed;