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 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 09:19:36 +02:00
co-authored by Claude Haiku 4.5
parent fe57429603
commit 45c37a5e73
5 changed files with 383 additions and 29 deletions
+6
View File
@@ -311,6 +311,12 @@
<img src="assets/loading-video.svg" id="load-video" class="load-media"> <img src="assets/loading-video.svg" id="load-video" class="load-media">
<canvas id="canvas"></canvas> <canvas id="canvas"></canvas>
</div> </div>
<div id="browser-handle" class="panel-handle noselect" title="Drag to resize">
<div class="panel-toggle" id="browser-toggle"></div>
</div>
<div id="properties-handle" class="panel-handle noselect" title="Drag to resize">
<div class="panel-toggle" id="properties-toggle"></div>
</div>
<div id="timeline-handle"></div> <div id="timeline-handle"></div>
<div id="bottom-area" class="noselect"> <div id="bottom-area" class="noselect">
<div id="keyframe-properties"> <div id="keyframe-properties">
+11
View File
@@ -53,6 +53,8 @@ function checkDB() {
'cornerRadius', 'cornerRadius',
'selectable', 'selectable',
'hasControls', 'hasControls',
'hasBorders',
'evented',
'subTargetCheck', 'subTargetCheck',
'id', 'id',
'hoverCursor', 'hoverCursor',
@@ -230,6 +232,8 @@ async function autoSave() {
'cornerRadius', 'cornerRadius',
'selectable', 'selectable',
'hasControls', 'hasControls',
'hasBorders',
'evented',
'subTargetCheck', 'subTargetCheck',
'id', 'id',
'hoverCursor', 'hoverCursor',
@@ -348,6 +352,13 @@ function loadProject() {
}); });
replaceSource(canvas.getItemById(object.id), canvas); replaceSource(canvas.getItemById(object.id), canvas);
} else { } 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)); renderProp('volume', canvas.getItemById(object.id));
} }
}); });
+211
View File
@@ -4381,6 +4381,11 @@ function newAudioLayer(src) {
duration: audio.duration * 1000, duration: audio.duration * 1000,
opacity: 0, opacity: 0,
selectable: false, 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, volume: 0.5,
assetType: 'audio', assetType: 'audio',
shadow: { shadow: {
@@ -5367,6 +5372,10 @@ function resetHeight() {
'calc(100% - ' + (top + 97) + 'px)' 'calc(100% - ' + (top + 97) + 'px)'
); );
$('#properties').css('height', '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); $('#timeline-handle').css('bottom', top + 95);
resizeCanvas(); resizeCanvas();
} }
@@ -5399,6 +5408,208 @@ $(document).on('pointerdown', '#timeline-handle', dragTimeline);
oldtimelinepos = $(window).height() - 92 - $('#timearea').height(); 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) // Sync scrolling (vertical)
function syncScroll(el1, el2) { function syncScroll(el1, el2) {
var $el1 = $(el1); var $el1 = $(el1);
+14 -5
View File
@@ -1219,12 +1219,13 @@ $(document).on(
// Switch tool // Switch tool
function switchTool(e) { function switchTool(e) {
$('#browser').removeClass('collapsed');
$('#canvas-area').removeClass('canvas-full');
if ($(this).attr('id') == 'more-tool') { if ($(this).attr('id') == 'more-tool') {
showMore(); showMore();
return false; return false;
} }
$('#browser').removeClass('collapsed');
$('#behind-browser').removeClass('collapsed');
applyPanelWidths();
resizeCanvas(); resizeCanvas();
var act = $('.tool-active'); var act = $('.tool-active');
if (act.attr('id') == 'image-tool') { if (act.attr('id') == 'image-tool') {
@@ -1292,6 +1293,10 @@ function dragObject(e) {
if (e.which == 3) { if (e.which == 3) {
return false; return false;
} }
// Measure before the clone leaves the panel - panel items are sized in
// percentages of a resizable panel, so a clone parented to <body> would
// report the body width instead.
var sourcewidth = $(this).width();
var drag = $(this).clone(); var drag = $(this).clone();
drag.css({ drag.css({
background: 'transparent', background: 'transparent',
@@ -1304,7 +1309,7 @@ function dragObject(e) {
zIndex: 9999999, zIndex: 9999999,
left: $(this).offset().left, left: $(this).offset().left,
top: $(this).offset().top, top: $(this).offset().top,
width: canvas.getZoom() * drag.width(), width: canvas.getZoom() * sourcewidth,
pointerEvents: 'none', pointerEvents: 'none',
opacity: 0, opacity: 0,
}); });
@@ -1619,10 +1624,13 @@ $(document).on(
// Collapse library // Collapse library
function collapsePanel() { 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'); $('#browser').addClass('collapsed');
$('#behind-browser').addClass('collapsed'); $('#behind-browser').addClass('collapsed');
$('#canvas-area').addClass('canvas-full');
var act = $('.tool-active');
if (act.attr('id') == 'image-tool') { if (act.attr('id') == 'image-tool') {
act.find('img').attr('src', 'assets/image.svg'); act.find('img').attr('src', 'assets/image.svg');
} else if (act.attr('id') == 'text-tool') { } else if (act.attr('id') == 'text-tool') {
@@ -1637,6 +1645,7 @@ function collapsePanel() {
act.find('img').attr('src', 'assets/uploads.svg'); act.find('img').attr('src', 'assets/uploads.svg');
} }
$('.tool-active').removeClass('tool-active'); $('.tool-active').removeClass('tool-active');
applyPanelWidths();
resizeCanvas(); resizeCanvas();
} }
$(document).on('click', '#collapse', collapsePanel); $(document).on('click', '#collapse', collapsePanel);
+141 -24
View File
@@ -7,6 +7,14 @@
--input-color: #22233e; --input-color: #22233e;
--accent-color: #166ef1; --accent-color: #166ef1;
--button-hover: #262746; --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 { :root {
@@ -41,7 +49,7 @@ body {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
background: var(--panel-back); background: var(--panel-back);
width: 76px; width: var(--rail-w);
border-right: 1px solid var(--panel-stroke); border-right: 1px solid var(--panel-stroke);
box-sizing: border-box; box-sizing: border-box;
} }
@@ -244,7 +252,7 @@ body {
#toolbar { #toolbar {
position: absolute; position: absolute;
height: calc(100% - 340px); height: calc(100% - 340px);
width: 76px; width: var(--rail-w);
background-color: var(--panel-back); background-color: var(--panel-back);
border-right: 1px solid var(--panel-stroke); border-right: 1px solid var(--panel-stroke);
left: 0px; left: 0px;
@@ -308,8 +316,8 @@ body {
#behind-browser { #behind-browser {
position: absolute; position: absolute;
height: 100%; height: 100%;
width: 299px; width: var(--browser-w);
left: 76px; left: var(--rail-w);
background: var(--panel-back); background: var(--panel-back);
border-right: 1px solid var(--panel-stroke); border-right: 1px solid var(--panel-stroke);
z-index: 1; z-index: 1;
@@ -318,10 +326,10 @@ body {
#browser { #browser {
position: absolute; position: absolute;
height: calc(100% - 450px); height: calc(100% - 450px);
width: 299px; width: var(--browser-w);
background-color: var(--panel-back); background-color: var(--panel-back);
border-right: 1px solid var(--panel-stroke); border-right: 1px solid var(--panel-stroke);
left: 76px; left: var(--rail-w);
top: 110px; top: 110px;
box-sizing: border-box; box-sizing: border-box;
z-index: 999999; z-index: 999999;
@@ -333,7 +341,7 @@ body {
display: none !important; display: none !important;
} }
#browser-container { #browser-container {
width: 260px; width: calc(var(--browser-w) - 39px);
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
height: 100%; height: 100%;
@@ -359,7 +367,7 @@ body {
} }
.image-grid-item, .image-grid-item,
.video-grid-item { .video-grid-item {
width: 120px; width: 100%;
margin-bottom: 15px; margin-bottom: 15px;
position: relative; position: relative;
} }
@@ -388,7 +396,7 @@ body {
text-align: right; text-align: right;
text-decoration: none; text-decoration: none;
text-shadow: 0px 1px 5px #000000; text-shadow: 0px 1px 5px #000000;
width: 110px; width: calc(100% - 10px);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -436,7 +444,9 @@ body {
grid-gap: 15px; grid-gap: 15px;
width: 100%; width: 100%;
grid-auto-columns: auto; 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; overflow: hidden;
} }
.scroll-row:before { .scroll-row:before {
@@ -461,7 +471,7 @@ body {
#search-fixed { #search-fixed {
position: fixed; position: fixed;
top: 0px; top: 0px;
width: 279px; width: calc(var(--browser-w) - 20px);
padding-left: 19px; padding-left: 19px;
margin-left: -19px; margin-left: -19px;
z-index: 999999; z-index: 999999;
@@ -776,12 +786,13 @@ body {
position: absolute; position: absolute;
right: 0px; right: 0px;
top: 0px; top: 0px;
width: 300px; width: var(--props-w);
height: calc(100% - 340px); height: calc(100% - 340px);
background-color: var(--panel-back); background-color: var(--panel-back);
border-left: 1px solid var(--panel-stroke); border-left: 1px solid var(--panel-stroke);
z-index: 999; z-index: 999;
overflow-y: overlay; overflow-y: overlay;
overflow-x: hidden;
} }
#properties-overlay { #properties-overlay {
width: 100%; width: 100%;
@@ -830,9 +841,12 @@ hr {
background-color: var(--panel-stroke); background-color: var(--panel-stroke);
margin-bottom: 20px; margin-bottom: 20px;
} }
#properties hr {
width: calc(var(--props-w) - 40px);
}
/* Property sections */ /* Property sections */
.panel-section { .panel-section {
width: 260px; width: calc(var(--props-w) - 40px);
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
@@ -869,8 +883,17 @@ th {
width: 189px; width: 189px;
display: flex; display: flex;
align-items: center; 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; 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 */ /* Dropdows */
.nice-select, .nice-select,
.list, .list,
@@ -1550,16 +1573,12 @@ label span {
#canvas-area { #canvas-area {
position: absolute; position: absolute;
top: 0px; top: 0px;
left: 375px; left: calc(var(--rail-w) + var(--browser-w));
height: calc(100% - 342px); height: calc(100% - 342px);
width: calc(100% - 675px); width: calc(100% - var(--rail-w) - var(--browser-w) - var(--props-w));
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
} }
.canvas-full {
left: 76px !important;
width: calc(100% - 376px) !important;
}
.canvas-container { .canvas-container {
left: 0px; left: 0px;
top: 0px; top: 0px;
@@ -1614,7 +1633,7 @@ canvas {
#layer-list { #layer-list {
position: absolute; position: absolute;
height: 245px; height: 245px;
width: 375px; width: var(--layers-w);
left: 0px; left: 0px;
bottom: 60px; bottom: 60px;
background-color: var(--panel-back); background-color: var(--panel-back);
@@ -1816,7 +1835,7 @@ layer:nth-child(even) .properties {
color: var(--secondary-text-color); color: var(--secondary-text-color);
line-height: 40px; line-height: 40px;
height: 40px; height: 40px;
width: 375px; width: var(--layers-w);
position: fixed; position: fixed;
margin-top: -35px; margin-top: -35px;
background-color: var(--panel-back); background-color: var(--panel-back);
@@ -1840,10 +1859,10 @@ layer:nth-child(even) .properties {
#timearea { #timearea {
position: absolute; position: absolute;
bottom: 60px; bottom: 60px;
left: 375px; left: var(--layers-w);
height: 245px; height: 245px;
background-color: var(--main-back); background-color: var(--main-back);
width: calc(100% - 375px); width: calc(100% - var(--layers-w));
border-top: 1px solid var(--panel-stroke); border-top: 1px solid var(--panel-stroke);
z-index: 999; z-index: 999;
} }
@@ -1858,10 +1877,108 @@ layer:nth-child(even) .properties {
cursor: ns-resize; cursor: ns-resize;
background-color: var(--panel-stroke); 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 { #seekarea {
height: 100%; height: 100%;
z-index: 99999; 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-x: auto;
overflow-y: hidden; overflow-y: hidden;
position: fixed; position: fixed;