diff --git a/TODO-FIXES.md b/TODO-FIXES.md index 097a382..e119678 100644 --- a/TODO-FIXES.md +++ b/TODO-FIXES.md @@ -315,3 +315,56 @@ held the Playwright profile lock), so the following still needs a manual pass: console shows `Rendering n%` (frame-accurate path) and not `Falling back to the real-time encoder` 4. Play the result: check audio is in sync and the video layer is not smeared + +--- + +## Upstream open issues (alyssaxuu/motionity) + +The 13 bug reports still open upstream were replayed in Chromium against this +fork. Nine were already fixed by the audit above — mp4 export (#16), blank +render (#25), image download (#10), audio on download (#30), filters not +retained (#24), `EyeDropper is not defined` (#15, #18), the endless +*Loading video…* (#28) and text selection while resizing the timeline (#5). +GIF export (#21) works through the same ffmpeg path. #8 (video from the search +tab) needs a Pixabay API key, so it stays untested; #29 and #4 carry no +reproducer. The remaining three are fixed here: + +- [x] **#23 — Border radius behaved like a percentage** — `src/js/functions.js`, + `src/js/ui.js`, `src/js/events.js`, `src/js/database.js` + fabric applies `rx`/`ry` before the object's scale, and shapes are resized by + scaling, so a radius typed as 20 drew at 60 px on a rect scaled 3x while the + panel — which read `rx` back raw — still said 20. A new `cornerRadius` property + stores the pixel value the user asked for; `rx`/`ry` are derived from it + (`setCornerRadius`) and re-derived on `object:scaling` / `object:modified` + (`syncCornerRadius`), so the same number means the same pixels at any size. + `cornerRadius` was added to the four serialised property lists, and + `getCornerRadius` falls back to `rx * scaleX` for projects saved before it + existed. Known limit, unchanged from before: if `scaleX` is *keyframed*, the + drawn radius still varies over the animation. + Verified: typed 20 px draws a 20 px corner at scale 1 and at scale 3, the + panel keeps reading 20, and the value survives save, JSON round-trip and a + page reload. + +- [x] **#27 — Cropping a rotated image** — `src/js/functions.js` + `crop()` compared canvas-space edges and covered only three of the four + quadrants, so any rotated image cropped the wrong region — and nothing at all + when the crop window was centred on it, since all three branches need a + strictly positive offset. The guards added in the audit stopped the crash but + left the geometry wrong. `crop()` now works in the image's own frame + (`rotateVector`), clamps the region to the bitmap, and re-centres the object on + the region it kept; the crop window is created with the image's `angle`, and + the "expand back to the full bitmap" shift in `cropImage` is rotated the same + way. The four-branch soup is gone. + Verified: at 0°, 30° and 45°, the pixels under the crop window are byte + identical before and after the crop, everything outside it is dropped, and + crop mode exits cleanly. + +- [x] **#1 — A modal did not block the timeline** — `src/js/functions.js` + Modals are painted over the timeline but never took its pointer events, so the + resize handle, the seekbar, keyframes and layer bars all still reacted to a + drag behind the dialog. The onboarding modal from the report does not exist in + the open-source build; the export, import/export and credits modals all had + the bug. `dragTimeline`, `dragSeekBar`, `dragKeyframe` and `dragObjectProps` + now bail while `.modal-open` is present. + Verified: with the export modal open a real mouse drag on the handle leaves + the timeline height untouched, and dragging works again once it closes. 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 a52c770..c658067 100644 --- a/src/js/database.js +++ b/src/js/database.js @@ -50,8 +50,11 @@ function checkDB() { 'strokeUniform', 'rx', 'ry', + 'cornerRadius', 'selectable', 'hasControls', + 'hasBorders', + 'evented', 'subTargetCheck', 'id', 'hoverCursor', @@ -226,8 +229,11 @@ async function autoSave() { 'strokeUniform', 'rx', 'ry', + 'cornerRadius', 'selectable', 'hasControls', + 'hasBorders', + 'evented', 'subTargetCheck', 'id', 'hoverCursor', @@ -346,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/events.js b/src/js/events.js index 0036fab..9e3b614 100644 --- a/src/js/events.js +++ b/src/js/events.js @@ -100,6 +100,8 @@ $(document).ready(function () { canvas.on('object:scaling', function (e) { e.target.hasControls = false; centerLines(e); + // Keep the corner radius at its pixel value while the handle is dragged + syncCornerRadius(e.target); if (cropping) { updateCropBounds(); crop(canvas.getItemById('cropped')); @@ -135,12 +137,16 @@ $(document).ready(function () { canvas.renderAll(); if (e.target.type == 'activeSelection') { const tempselection = canvas.getActiveObject(); + // Discarding first bakes the group transform into the children, so + // their scale is final by the time the radius is re-derived canvas.discardActiveObject(); e.target._objects.forEach(function (object) { + syncCornerRadius(object); autoKeyframe(object, e, true); }); reselect(tempselection); } else { + syncCornerRadius(e.target); autoKeyframe(e.target, e, false); } updatePanelValues(); diff --git a/src/js/functions.js b/src/js/functions.js index b0596fb..3c0fd2b 100644 --- a/src/js/functions.js +++ b/src/js/functions.js @@ -495,6 +495,7 @@ async function updateRecordCanvas() { 'strokeUniform', 'rx', 'ry', + 'cornerRadius', 'selectable', 'hasControls', 'subTargetCheck', @@ -684,6 +685,7 @@ function save() { 'strokeUniform', 'rx', 'ry', + 'cornerRadius', 'selectable', 'hasControls', 'subTargetCheck', @@ -1050,13 +1052,57 @@ $(document).on('click', '#redo', function () { } }); +// Corner radius, in the canvas pixels the panel advertises. +// +// fabric applies rx/ry in the object's own coordinate space, before its scale, +// and shapes are resized by scaling rather than by changing width/height. The +// raw input was written straight to rx, so a radius typed as 20 drew at 60 on a +// rect scaled 3x: the value behaved like a proportion of the shape instead of +// pixels. `cornerRadius` stores what the user asked for and rx/ry are derived +// from it, so the same number means the same number of pixels at any size. +function getCornerRadius(object) { + if (!object) { + return 0; + } + if (typeof object.cornerRadius == 'number') { + return object.cornerRadius; + } + // Projects saved before cornerRadius existed only carry the raw rx + return ( + (object.get('rx') || 0) * Math.abs(object.get('scaleX') || 1) + ); +} + +function setCornerRadius(object, radius) { + if (!object) { + return; + } + var px = radius > 0 ? radius : 0; + object.cornerRadius = px; + object.set({ + rx: px / Math.abs(object.get('scaleX') || 1), + ry: px / Math.abs(object.get('scaleY') || 1), + }); +} + +// Re-derive rx/ry after a resize so the drawn radius keeps the typed value +function syncCornerRadius(object) { + if (!object || object.get('type') != 'rect') { + return; + } + if (typeof object.cornerRadius != 'number') { + return; + } + setCornerRadius(object, object.cornerRadius); +} + // Generate keyframes function keyframeChanges(object, type, id, selection) { if (object.get('type') == 'rect') { - object.set({ - rx: parseFloat($('#object-corners input').val()), - ry: parseFloat($('#object-corners input').val()), - }); + setCornerRadius( + object, + parseFloat($('#object-corners input').val()) + ); } else if (object.get('type') == 'textbox') { object.set({ charSpacing: parseFloat($('#text-h input').val()) * 10, @@ -3512,7 +3558,23 @@ function checkCrop(obj) { crop(canvas.getItemById('cropped')); } +// Rotate a vector by `radians` around the origin +function rotateVector(x, y, radians) { + if (!radians) { + return { x: x, y: y }; + } + var cos = Math.cos(radians); + var sin = Math.sin(radians); + return { x: x * cos - y * sin, y: x * sin + y * cos }; +} + // Perform a crop +// +// Everything is computed in the image's own frame rather than along the canvas +// axes, so a rotated image crops the region the crop window actually covers. +// The previous version compared canvas-space edges and only handled three of +// the four quadrants, which cropped the wrong area of any rotated image (and +// nothing at all when the crop window was centred on it). function crop(obj) { var cropUI = canvas.getItemById('crop'); if (!obj || !cropUI || !cropobj) { @@ -3520,58 +3582,54 @@ function crop(obj) { } cropobj.setCoords(); cropUI.setCoords(); - var cleft = - cropUI.get('left') - (cropUI.get('width') * cropUI.get('scaleX')) / 2; - var ctop = - cropUI.get('top') - (cropUI.get('height') * cropUI.get('scaleY')) / 2; - var height = - (cropUI.get('height') / cropobj.get('scaleY')) * cropUI.get('scaleY'); - var width = - (cropUI.get('width') / cropobj.get('scaleX')) * cropUI.get('scaleX'); - var img_height = cropobj.get('height') * cropobj.get('scaleY'); - var img_width = cropobj.get('width') * cropobj.get('scaleX'); - var left = - cleft - - (cropobj.get('left') - - (cropobj.get('width') * cropobj.get('scaleX')) / 2); - var top = - ctop - - (cropobj.get('top') - - (cropobj.get('height') * cropobj.get('scaleY')) / 2); - if (left < 0 && top > 0) { - obj - .set({ cropY: top / cropobj.get('scaleY'), height: height }) - .setCoords(); - canvas.renderAll(); - obj.set({ - top: ctop + (obj.get('height') * obj.get('scaleY')) / 2, - }); - canvas.renderAll(); - } else if (top < 0 && left > 0) { - obj - .set({ cropX: left / cropobj.get('scaleX'), width: width }) - .setCoords(); - canvas.renderAll(); - obj.set({ - left: cleft + (obj.get('width') * obj.get('scaleX')) / 2, - }); - canvas.renderAll(); - } else if (top > 0 && left > 0) { - obj - .set({ - cropX: left / cropobj.get('scaleX'), - cropY: top / cropobj.get('scaleY'), - height: height, - width: width, - }) - .setCoords(); - canvas.renderAll(); - obj.set({ - left: cleft + (obj.get('width') * obj.get('scaleX')) / 2, - top: ctop + (obj.get('height') * obj.get('scaleY')) / 2, - }); - canvas.renderAll(); - } + // Read everything before writing: the final call passes cropobj as `obj` + var scaleX = cropobj.get('scaleX') || 1; + var scaleY = cropobj.get('scaleY') || 1; + var ogWidth = cropobj.get('width'); + var ogHeight = cropobj.get('height'); + var centerX = cropobj.get('left'); + var centerY = cropobj.get('top'); + var radians = fabric.util.degreesToRadians( + cropobj.get('angle') || 0 + ); + + // The crop window carries the image's angle, so its own width and height are + // already axis-aligned with the image + var width = (cropUI.get('width') * cropUI.get('scaleX')) / scaleX; + var height = (cropUI.get('height') * cropUI.get('scaleY')) / scaleY; + + // Where the crop window sits relative to the image centre, un-rotated + var offset = rotateVector( + cropUI.get('left') - centerX, + cropUI.get('top') - centerY, + -radians + ); + var cropX = offset.x / scaleX + ogWidth / 2 - width / 2; + var cropY = offset.y / scaleY + ogHeight / 2 - height / 2; + + // Never leave the source bitmap: fabric draws nothing for a negative crop + width = Math.max(1, Math.min(width, ogWidth)); + height = Math.max(1, Math.min(height, ogHeight)); + cropX = Math.max(0, Math.min(cropX, ogWidth - width)); + cropY = Math.max(0, Math.min(cropY, ogHeight - height)); + + // Put the cropped object back under the (clamped) region it represents + var center = rotateVector( + (cropX + width / 2 - ogWidth / 2) * scaleX, + (cropY + height / 2 - ogHeight / 2) * scaleY, + radians + ); + obj + .set({ + cropX: cropX, + cropY: cropY, + width: width, + height: height, + left: centerX + center.x, + top: centerY + center.y, + }) + .setCoords(); + canvas.renderAll(); if (obj.get('id') != 'cropped') { canvas.remove(cropUI); canvas.remove(canvas.getItemById('crop-overlay')); @@ -3626,20 +3684,23 @@ function cropImage(object) { cropobj = object; canvas.uniformScaling = false; cropobj.setCoords(); - var left = - cropobj.get('left') - - (cropobj.get('width') * cropobj.get('scaleX')) / 2; - var top = - cropobj.get('top') - - (cropobj.get('height') * cropobj.get('scaleY')) / 2; + // The visible region, in the source bitmap's own pixels, before the image + // is expanded back to its full size below var cropx = cropobj.get('cropX'); var cropy = cropobj.get('cropY'); + var shownWidth = cropobj.get('width'); + var shownHeight = cropobj.get('height'); + var shownLeft = cropobj.get('left'); + var shownTop = cropobj.get('top'); overlay(); var cropUI = new fabric.Rect({ left: object.get('left'), top: object.get('top'), width: object.get('width') * object.get('scaleX') - 5, height: object.get('height') * object.get('scaleY') - 5, + // The crop window has to turn with the image, otherwise the region it + // covers cannot be expressed as a crop at all + angle: object.get('angle') || 0, originX: 'center', originY: 'center', id: 'crop', @@ -3674,15 +3735,19 @@ function cropImage(object) { }) .setCoords(); canvas.renderAll(); + // Growing back to the full bitmap moves the centre, so shift the image so + // the region that was on screen stays exactly where it was. The offset is + // in the image's own frame, hence the rotation. + var shift = rotateVector( + (cropx + shownWidth / 2 - cropobj.get('width') / 2) * + cropobj.get('scaleX'), + (cropy + shownHeight / 2 - cropobj.get('height') / 2) * + cropobj.get('scaleY'), + fabric.util.degreesToRadians(cropobj.get('angle') || 0) + ); cropobj.set({ - left: - left + - (cropobj.get('width') * cropobj.get('scaleX')) / 2 - - cropx * cropobj.get('scaleX'), - top: - top + - (cropobj.get('height') * cropobj.get('scaleY')) / 2 - - cropy * cropobj.get('scaleY'), + left: shownLeft - shift.x, + top: shownTop - shift.y, }); cropUI.setControlsVisibility({ mt: false, @@ -4058,6 +4123,7 @@ function newRectangle(color) { backgroundColor: 'rgba(255,255,255,0)', rx: 0, ry: 0, + cornerRadius: 0, fill: color, cursorWidth: 1, cursorDuration: 1, @@ -4315,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: { @@ -4862,9 +4933,17 @@ function bindPointerDrag(e, el, onMove, onEnd) { window.addEventListener('blur', end); } +// A modal covers the timeline but does not cover pointer events, so every +// timeline drag has to opt out while one is open - otherwise the seekbar, +// keyframes, layer bars and the resize handle all still react to a drag +// happening "behind" the dialog. +function modalOpen() { + return $('.modal-open').length > 0; +} + // Dragging a keyframe function dragKeyframe(e) { - if (e.which == 3) { + if (e.which == 3 || modalOpen()) { return false; } e.stopPropagation(); @@ -5014,7 +5093,7 @@ function updateTime(drag, check) { // Dragging the seekbar function dragSeekBar(e) { - if (e.which == 3) { + if (e.which == 3 || modalOpen()) { return false; } // Stop the browser from turning the press into a native drag-and-drop. @@ -5085,7 +5164,7 @@ $(document).on( // Dragging layer horizontally function dragObjectProps(e) { - if (e.which == 3) { + if (e.which == 3 || modalOpen()) { return false; } var drag = $(this).parent(); @@ -5293,13 +5372,17 @@ 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(); } // Dragging timeline vertically function dragTimeline(e) { - if (e.which == 3) { + if (e.which == 3 || modalOpen()) { return false; } // Suppress text selection for the duration of the drag only - leaving these @@ -5325,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 4add356..e8e2da7 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -570,7 +570,7 @@ function updatePanelValues() { o_slider.setValue(object.get('opacity') * 100); if (object.get('type') == 'rect') { $('#object-corners input').val( - parseFloat(object.get('rx').toFixed(2)) + parseFloat(getCornerRadius(object).toFixed(2)) ); colormode = 'fill'; o_fill.setColor(object.get('fill')); @@ -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;