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;