diff --git a/TODO-FIXES.md b/TODO-FIXES.md new file mode 100644 index 0000000..6aa3a8e --- /dev/null +++ b/TODO-FIXES.md @@ -0,0 +1,273 @@ +# Bugs & Issues — audit + fixes + +Audit of `src/js`, then all findings fixed. Every file below passes `node --check`. +No live browser smoke test was run (a Chrome instance held the Playwright profile +lock), so the changes are verified statically only — see **Not verified** at the end. + +Legend: `[x]` fixed · P0 = feature broken · P1 = wrong data written · P2 = crash on edge path · P3 = silently wrong UI · P4 = perf/leak · P5 = cleanup + +--- + +## P0 — Broken features + +- [x] **MP4 export dead-ended and locked the UI** — `src/js/functions.js` + The mp4 branch of `downloadRecording` was `type = 'video/mp4'`, an implicit global + assignment that did nothing: no download, `recording` stuck at `true`, the button + stuck on "Downloading...", and `downloadModal()` refusing to reopen until reload. + mp4 now goes through `convertStreams(blob, 'mp4')` like gif, and a new shared + `resetRecordingUI()` unlocks the editor on every exit path (success, unknown + format, worker error, FileReader error, `MediaRecorder` error). + +- [x] **Copying keyframes threw** — `src/js/events.js` + `canvas.getActiveObject().isEditing` ran on `null`, because selecting keyframes + clears the canvas selection — the normal path. The active object is now resolved + once and guarded, and empty `$.grep` results are no longer pushed to the clipboard. + +- [x] **`canvas.getItemByid` typo** — `src/js/functions.js` + Lowercase `i`; TypeError when pasting text/`charSpacing` keyframes. + +- [x] **Ctrl+Z crashed on an empty stack; Ctrl+Shift+Z was a no-op** — `src/js/events.js` + Merged into one guarded handler: shift picks redo, and each branch checks its own + stack length. Previously Ctrl+Shift+Z matched both `if` blocks (redo then undo). + +- [x] **Blur slider threw with nothing selected** — `src/js/events.js` + `obj.applyFilters()` was outside the `if (canvas.getActiveObject())` guard. Also + renamed the shadowed `x` in the blur/noise/chroma `find()` callbacks. + +## P1 — Wrong values written + +- [x] **`height` keyframes stored the width** — `src/js/functions.js` (3 sites: twice in `keyframeChanges`, once in `crop`) +- [x] **`var scaleX = obj, scaleX;`** — `src/js/text.js`; the fabric object was being assigned as a scale factor. +- [x] **NaN letter delay + shadowed global `duration`** — `src/js/text.js` + `delay = i * duration` read the hoisted local before its own initialiser. Renamed + to `step`, computed before use, and `!(delay > 0)` now catches NaN. +- [x] **Every letter animation wrote to the last letter** — `src/js/text.js` + `index`, `animation`, `start` and `instance` are `let` per iteration instead of `var`. +- [x] **Shadow defaults/keyframes stored `undefined`** — `src/js/functions.js` + fabric's `get()` is not a path getter. Added `getPropValue()` (handles `shadow.*`) + and `setDefaultValue()` / `getDefaultValue()`, and routed ~26 direct + `.defaults.find(...).value = …` writes through them — which also creates the entry + when a project predates a property instead of throwing. +- [x] **WebGL filter backend clobbered with `undefined`** — `src/js/init.js`, `src/js/database.js` + Both assignments are now conditional on the backend having constructed. + +## P2 — Crashes on edge paths + +- [x] **`RangeError: Invalid array length`** — `src/js/functions.js` + `temparr.length = findIndex(...)` went negative for a stale keyframe reference. + `lastKeyframe` / `nextKeyframe` are now index lookups that return `false`. +- [x] **`animate()` had no null guards** — `src/js/functions.js` + Object and `p_keyframes` lookups are resolved once and guarded in every per-frame + loop (`animate`, `recordAnimate`, the playback update loop, `playVideos`, + `playAudio`), which also removed dozens of repeated `.find()` calls. +- [x] **`getAssets()` recursed synchronously forever** — `src/js/database.js` + Retries on a 250 ms timer, capped at 20 attempts, and rebuilds the asset arrays + instead of appending duplicates on re-entry after an import. +- [x] **`deleteObject` threw and leaked `files`** — `src/js/functions.js` + The entry was compared to a string so it never matched; now filtered by `name`. + Video elements are also paused and unloaded on delete. +- [x] **Unguarded `keyarr[0]` / `.defaults.find(...)`** — `copyKeyframes`, `updateKeyframe`, + `applyEasing`, `keyframeProperties`, `removeKeyframe`, `checkAnyKeyframe`. + The four repetitive counterpart blocks were replaced by one + `KEYFRAME_COUNTERPARTS` map, so a missing counterpart is skipped, not fatal. +- [x] **Other unguarded lookups** — `deleteAsset`, `reGroup`, `renderLayer`, + `renderProp`, `setDuration`, `setTimelineZoom`, `saveLayerName`, `updateInputs`, + `updatePanel`, `updateStrokeValues`, `animateText`, `scrollIntoView` (3 sites), + `object:modified` / `object:rotating` / `mouse:out` / `mouse:up` handlers. + `importProject` validates the payload before touching `data.project[0]`, and + `line_h`/`line_v` go through a new `hideGuides()` helper. + +## P3 — Silently wrong behaviour + +- [x] **`document.onmousedown` permanently disabled** — `src/js/functions.js` + `dragTimeline` installed a `return false` handler and never removed it. It now + only overrides `onselectstart`, and restores it on mouseup. +- [x] **Keyframe time drifted on every drag** — `src/js/functions.js` + `data-time` is now always the absolute timeline time (the value every lookup keys + off), with the visual offset applied as CSS only. This also fixes keyframes on an + *expanded* row, whose `data-time` used to be layer-relative so no lookup matched. + `updateKeyframe`'s unused third argument is gone. +- [x] **Shift-deselect never removed a keyframe** — `this` inside a `$.grep` callback is not the element. +- [x] **`e.shiftDown`** → `e.shiftKey`. +- [x] **Snap guide never hid** — the row-local index was compared against the global `.keyframe` count. +- [x] **Comparison / typo bugs** + `canvas.getActiveObjects.length` → `getActiveObjects().length` · + `strokeDashArray == [10, 5]` → element comparison · + chroma `setValue(distance)` → `distance * 100` · `'#FFFFF'` → `'#FFFFFF'` · + `videoPlayer.videoheight` → `videoHeight` (and both thumbnail helpers no longer + draw the canvas onto itself) · `if (start && play && !paused)` in `playAudio` + (`play` is the global function, always truthy) · `#redo` gated on `redo.length` · + `:last-child()` → `:last-child`. +- [x] **Paste loop closure** — `var imgObj` → `let`, so each thumbnail saves its own file. +- [x] **Layers added mid-timeline were shortened** — `end: duration - currenttime` → `duration` + (media layers clamp to `min(start + assetDuration, duration)`). +- [x] **Malformed HTML** — 5 unterminated `` in `src/js/ui.js`. +- [x] **Duplicate DOM ids** — `id="easing"` on both wrapper and ` +
+

Frame rate

+ +
Download
@@ -168,7 +182,7 @@
-
Filters
+
Filters

-
Adjustments
+
Adjustments
Reset
Brightness @@ -216,7 +230,7 @@

-
Chroma key
+
Chroma key
Status @@ -242,7 +256,7 @@

-
Stylize
+
Stylize
Noise @@ -289,7 +303,7 @@

Keyframe easing

- @@ -366,16 +380,16 @@ - - - + + + - - + + - + @@ -383,6 +397,8 @@ + + diff --git a/src/js/align.js b/src/js/align.js index c59eaa6..c078f8e 100644 --- a/src/js/align.js +++ b/src/js/align.js @@ -78,6 +78,20 @@ function initLines() { canvas.add(line_v); } +// Hide the alignment guides after a canvas reload. +// They can be missing entirely in projects saved before they existed, so +// never dereference them directly. +function hideGuides(inst) { + const h = inst.getItemById('line_h'); + const v = inst.getItemById('line_v'); + if (h) { + h.set({ opacity: 0 }); + } + if (v) { + v.set({ opacity: 0 }); + } +} + function alignControls(object, type) { if (type == 'align-top') { object.set( @@ -122,8 +136,10 @@ function alignControls(object, type) { function alignObject() { const type = $(this).attr('id'); const object = canvas.getActiveObject(); - console.log(canvas.getActiveObject().type); - if (canvas.getActiveObject().type == 'activeSelection') { + if (!object) { + return; + } + if (object.type == 'activeSelection') { const tempselection = canvas.getActiveObject(); canvas.discardActiveObject(); tempselection._objects.forEach(function (object) { @@ -157,6 +173,8 @@ function alignObject() { ); newKeyframe('top', object, currenttime, object.get('top'), true); } + canvas.renderAll(); + save(); } $(document).on('click', '.align', alignObject); @@ -240,7 +258,12 @@ function centerLines(e) { canvas.renderAll(); } } - if (obj != e.target && obj != line_h && obj != line_v) { + if ( + obj != e.target && + obj != line_h && + obj != line_v && + obj.visible !== false + ) { if ( obj.get('id') == 'center_h' || obj.get('id') == 'center_v' diff --git a/src/js/converter.js b/src/js/converter.js index 83e2668..928555c 100644 --- a/src/js/converter.js +++ b/src/js/converter.js @@ -1,7 +1,30 @@ -var workerPath = - 'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js'; +// MP4/GIF export transcodes the captured WebM with an asm.js build of ffmpeg. +// Packaged builds ship it locally (npm run vendor); a plain checkout falls back +// to the public mirror, which needs network access. +var FFMPEG_ASM_LOCAL = 'vendor/ffmpeg_asm.js'; +var FFMPEG_ASM_REMOTE = 'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js'; +var ffmpegAsmUrlPromise = null; -function processInWebWorker() { +// The worker is built from a blob, so importScripts() needs an absolute URL. +function resolveFfmpegAsmUrl() { + if (!ffmpegAsmUrlPromise) { + ffmpegAsmUrlPromise = fetch(FFMPEG_ASM_LOCAL, { method: 'HEAD' }) + .then(function (res) { + // A catch-all/SPA route answers 200 with HTML; that is not the script. + var type = res.headers.get('content-type') || ''; + var local = res.ok && type.indexOf('text/html') === -1; + return local + ? new URL(FFMPEG_ASM_LOCAL, location.href).href + : FFMPEG_ASM_REMOTE; + }) + .catch(function () { + return FFMPEG_ASM_REMOTE; + }); + } + return ffmpegAsmUrlPromise; +} + +function processInWebWorker(workerPath) { var blob = URL.createObjectURL( new Blob( [ @@ -21,37 +44,62 @@ function processInWebWorker() { } var worker; +// The worker is created once and reused, so its "ready" handshake only ever +// arrives for the first conversion. Remember it across calls. +var workerIsReady = false; -function convertStreams(videoBlob, setting) { +async function convertStreams(videoBlob, setting) { var aab; - var buffersReady; - var workerReady; - var posted; + var buffersReady = false; + var posted = false; + + function convertFailed(reason) { + console.error('Conversion failed: ' + reason); + alert( + 'Sorry, the ' + + setting.toUpperCase() + + ' conversion failed. The WEBM format is always available.' + ); + resetRecordingUI(); + } var fileReader = new FileReader(); fileReader.onload = function () { aab = this.result; - postMessage(); + buffersReady = true; + if (workerIsReady) postMessage(); + }; + fileReader.onerror = function () { + convertFailed('could not read the recorded video'); }; fileReader.readAsArrayBuffer(videoBlob); if (!worker) { - worker = processInWebWorker(); + // Safe to await here: workerIsReady is still false, so the FileReader + // callback cannot post a command before the worker exists. + worker = processInWebWorker(await resolveFfmpegAsmUrl()); } + worker.onerror = function (e) { + convertFailed(e.message || 'worker error'); + }; worker.onmessage = function (event) { var message = event.data; if (message.type == 'ready') { - workerReady = true; + workerIsReady = true; if (buffersReady) postMessage(); } else if (message.type == 'done') { - var result = message.data[0]; + var result = message.data && message.data[0]; + if (!result || !result.data) { + convertFailed('the encoder returned no data'); + return; + } if (setting == 'gif') { - var blob = new File([result.data], 'test.gif', { + var blob = new File([result.data], 'video.gif', { type: 'image/gif', }); PostBlob(blob); } else if (setting == 'mp4') { - var blob = new File([result.data], 'test.mp4', { + var blob = new File([result.data], 'video.mp4', { type: 'video/mp4', }); PostBlob(blob); @@ -59,11 +107,17 @@ function convertStreams(videoBlob, setting) { } }; var postMessage = function () { + if (posted) return; posted = true; + // The recording was made at this rate, so the transcode has to keep it: + // a fixed -r would duplicate or drop frames and drift the timing. + const fps = getExportFramerate(); if (setting == 'gif') { worker.postMessage({ type: 'command', - arguments: '-i video.webm -r 24 output-10.gif'.split(' '), + arguments: ('-i video.webm -r ' + fps + ' output-10.gif').split( + ' ' + ), files: [ { data: new Uint8Array(aab), @@ -74,10 +128,11 @@ function convertStreams(videoBlob, setting) { } else if (setting == 'mp4') { worker.postMessage({ type: 'command', - arguments: - '-i video.webm -c:v mpeg4 -b:v 6400k -strict experimental output.mp4'.split( - ' ' - ), + arguments: ( + '-i video.webm -c:v mpeg4 -b:v 6400k -r ' + + fps + + ' -strict experimental output.mp4' + ).split(' '), files: [ { data: new Uint8Array(aab), @@ -97,22 +152,9 @@ function PostBlob(blob) { a.download = blob.name || 'video'; document.body.appendChild(a); a.click(); - recording = false; - currenttime = 0; - animate(false, 0); - $('#seekbar').offset({ - left: - offset_left + - $('#inner-timeline').offset().left + - currenttime / timelinetime, - }); - canvas.renderAll(); - resizeCanvas(); - if (background_audio != false) { - background_audio.pause(); - background_audio = new Audio(background_audio.src); - } - $('#download-real').html('Download'); - $('#download-real').removeClass('downloading'); - updateRecordCanvas(); + document.body.removeChild(a); + window.setTimeout(function () { + URL.revokeObjectURL(url); + }, 60000); + resetRecordingUI(); } diff --git a/src/js/database.js b/src/js/database.js index c36ac33..a52c770 100644 --- a/src/js/database.js +++ b/src/js/database.js @@ -88,15 +88,24 @@ function checkDB() { } else { loadProject(); } + }) + .catch(function (e) { + console.error('Could not open the local project database', e); }); } // Automatically save project (locally) -function autoSave() { +async function autoSave() { if (checkstatus) { canvas.clipPath = null; - objects.forEach(async function (object) { + // Sequential, not forEach(async): toDatalessJSON below must not run + // while filters are still being stripped off the objects. + for (const object of objects) { var obj = canvas.getItemById(object.id); + if (!obj) { + object.filters = []; + continue; + } if (obj.filters) { if (obj.filters.length > 0) { object.filters = []; @@ -174,7 +183,7 @@ function autoSave() { } else { object.filters = []; } - }); + } const inst = canvas.toDatalessJSON([ 'volume', 'audioSrc', @@ -250,6 +259,9 @@ function autoSave() { activepreset: activepreset, width: artboard.width, height: artboard.height, + }) + .catch(function (e) { + console.error('Autosave failed', e); }); objects.forEach(function (object) { replaceSource(canvas.getItemById(object.id), canvas); @@ -289,17 +301,24 @@ function loadProject() { currenttime = 0; canvas.clipPath = null; canvas.clear(); - fabric.filterBackend = webglBackend; + if (webglBackend) { + fabric.filterBackend = webglBackend; + } f = fabric.Image.filters; canvas.loadFromJSON(JSON.parse(project.canvas), function () { canvas.clipPath = artboard; - canvas.getItemById('line_h').set({ opacity: 0 }); - canvas.getItemById('line_v').set({ opacity: 0 }); + hideGuides(canvas); canvas.renderAll(); $('.object-props').remove(); $('.layer').remove(); objects.forEach(function (object) { var animatethis = false; + if (!object.animate) { + object.animate = []; + } + if (!canvas.getItemById(object.id)) { + return; + } if (object.animate.length > 5) { if (isSameSet(object.animate, props)) { animatethis = true; @@ -331,6 +350,9 @@ function loadProject() { } }); keyframes.forEach(function (keyframe) { + if (!canvas.getItemById(keyframe.id)) { + return; + } if ( keyframe.name != 'top' && keyframe.name != 'scaleY' && @@ -510,12 +532,18 @@ function deleteAsset(key) { .doc({ key: key }) .get() .then((asset) => { + if (!asset) { + return; + } var temp = files.filter((x) => x.file == asset.src); if (temp.length > 0) { temp.forEach(function (file) { - deleteObject(canvas.getItemById(file.name)); + const object = canvas.getItemById(file.name); + if (object) { + deleteObject(object); + } files = $.grep(files, function (a) { - return a != file; + return a !== file; }); }); } @@ -534,14 +562,26 @@ function deleteAsset(key) { }); } -function getAssets() { +function getAssets(attempt) { + attempt = attempt || 0; db.collection('assets') .get() .then((assets) => { - // Sometimes the assets aren't ready when importing, really annoying + // Sometimes the assets aren't ready when importing, really annoying. + // Retry on a timer with a hard cap - a bare synchronous re-call spins + // the CPU forever when the collection never resolves. if (assets === undefined) { - getAssets(); + if (attempt < 20) { + window.setTimeout(function () { + getAssets(attempt + 1); + }, 250); + } else { + console.error('Could not read assets from the local database'); + } } else if (assets.length > 0) { + // Rebuild rather than append: getAssets() runs again after an import + uploaded_images = []; + uploaded_videos = []; assets.forEach(function (asset) { if (asset.type == 'image') { uploaded_images.push({ @@ -562,6 +602,9 @@ function getAssets() { } }); } + }) + .catch(function (e) { + console.error('Could not read assets', e); }); } @@ -570,10 +613,20 @@ function readTextFile(file, callback) { rawFile.overrideMimeType('application/json'); rawFile.open('GET', file, true); rawFile.onreadystatechange = function () { - if (rawFile.readyState === 4 && rawFile.status == '200') { - callback(rawFile.responseText); + if (rawFile.readyState === 4) { + // A blob: URL resolves with status 0 + if (rawFile.status == 200 || rawFile.status === 0) { + callback(rawFile.responseText); + } else { + alert('Could not read the file'); + $('#import-project span').html('Import'); + } } }; + rawFile.onerror = function () { + alert('Could not read the file'); + $('#import-project span').html('Import'); + }; rawFile.send(null); } @@ -582,10 +635,20 @@ async function importProject(e) { var file = e.target.files[0]; var path = (window.URL || window.webkitURL).createObjectURL(file); readTextFile(path, function (text) { - var data = JSON.parse(text); + var data; + try { + data = JSON.parse(text); + } catch (e) { + data = null; + } + if (!data || !Array.isArray(data.project) || data.project.length == 0) { + alert('Wrong file type'); + $('#import-project span').html('Import'); + return; + } delete data.project[0].id; - if (data.project.length > 0) { - if (data.assets.length > 0) { + { + if (Array.isArray(data.assets) && data.assets.length > 0) { data.assets.forEach(function (asset) { delete asset.id; db.collection('assets').add(asset); @@ -598,10 +661,14 @@ async function importProject(e) { $('#import-project span').html('Import'); hideModals(); loadProject(); + }) + .catch(function (e) { + console.error('Import failed', e); + alert('Import failed'); + $('#import-project span').html('Import'); }); - } else { - alert('Wrong file type'); } + window.URL.revokeObjectURL(path); }); } @@ -619,18 +686,24 @@ function exportProject() { .get() .then((assets) => { var exportarr = { project: project, assets: assets }; - $('', { - download: 'data.json', - href: - 'data:application/json,' + - encodeURIComponent(JSON.stringify(exportarr)), - }) - .appendTo('body') - .click(function () { - $(this).remove(); - $('#export-project span').html('Export'); - })[0] - .click(); + // Blob, not a data: URL - projects with media blow past the + // maximum URL length. + const url = URL.createObjectURL( + new Blob([JSON.stringify(exportarr)], { + type: 'application/json', + }) + ); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = 'data.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.setTimeout(function () { + URL.revokeObjectURL(url); + }, 60000); + $('#export-project span').html('Export'); }); } else { alert('Empty project'); @@ -649,11 +722,16 @@ function clearProject() { 'Are you sure you want to clear this project? This action cannot be undone.' ) ) { - db.collection('projects').delete(); - db.collection('assets').delete(); - window.setTimeout(function () { - location.reload(); - }, 1000); + Promise.all([ + db.collection('projects').delete(), + db.collection('assets').delete(), + ]) + .catch(function (e) { + console.error('Could not clear the project', e); + }) + .then(function () { + location.reload(); + }); } hideMore(); } diff --git a/src/js/events.js b/src/js/events.js index 8dbed56..0036fab 100644 --- a/src/js/events.js +++ b/src/js/events.js @@ -1,17 +1,79 @@ +// Remember the last valid crop rectangle so it can be restored if the user +// drags it outside the image. +function updateCropBounds() { + const cropUI = canvas.getItemById('crop'); + if (!cropUI || !cropobj) { + return; + } + if (cropUI.isContainedWithinObject(cropobj)) { + cropleft = cropUI.get('left'); + croptop = cropUI.get('top'); + cropscalex = cropUI.get('scaleX'); + cropscaley = cropUI.get('scaleY'); + } +} + +// Drag-to-reorder for the layer list. html5sortable only wires up the +// children present when it runs, so this is re-run every time a layer is +// added. The sortstop handler is bound only once. +let layerSortBound = false; +function initLayerSortable() { + const list = document.getElementById('layer-inner-list'); + if (!list) { + return; + } + const sortableList = sortable(list, { + handle: '.layer-handle', + customDragImage: (draggedElement, elementOffset, event) => { + return { + element: document.getElementById('nothing'), + posX: event.pageX - elementOffset.left, + posY: event.pageY - elementOffset.top, + }; + }, + })[0]; + + // Re-initializing marks every handle draggable again, so locked layers + // have to be opted back out. + $('#layer-inner-list .layer').each(function () { + if ($(this).find('.lock').hasClass('locked')) { + $(this).find('.layer-handle').attr('draggable', false); + } + }); + + if (layerSortBound) { + return; + } + layerSortBound = true; + sortableList.addEventListener('sortupdate', function () { + syncTimelineOrder(); + orderLayers(); + }); +} + +// Re-order the timeline rows to match the layer list +function syncTimelineOrder() { + const timeline = document.getElementById('inner-timeline'); + if (!timeline) { + return; + } + $('#layer-inner-list .layer').each(function () { + const row = document.getElementById( + $(this).attr('data-object') + ); + if (row) { + timeline.appendChild(row); + } + }); +} + $(document).ready(function () { // An object is being moved in the canvas canvas.on('object:moving', function (e) { e.target.hasControls = false; centerLines(e); if (cropping) { - if ( - canvas.getItemById('crop').isContainedWithinObject(cropobj) - ) { - cropleft = canvas.getItemById('crop').get('left'); - croptop = canvas.getItemById('crop').get('top'); - cropscalex = canvas.getItemById('crop').get('scaleX'); - cropscaley = canvas.getItemById('crop').get('scaleY'); - } + updateCropBounds(); crop(canvas.getItemById('cropped')); } else if ( lockmovement && @@ -39,14 +101,7 @@ $(document).ready(function () { e.target.hasControls = false; centerLines(e); if (cropping) { - if ( - canvas.getItemById('crop').isContainedWithinObject(cropobj) - ) { - cropleft = canvas.getItemById('crop').get('left'); - croptop = canvas.getItemById('crop').get('top'); - cropscalex = canvas.getItemById('crop').get('scaleX'); - cropscaley = canvas.getItemById('crop').get('scaleY'); - } + updateCropBounds(); crop(canvas.getItemById('cropped')); } }); @@ -56,24 +111,15 @@ $(document).ready(function () { e.target.hasControls = false; centerLines(e); if (cropping) { - if ( - canvas.getItemById('crop').isContainedWithinObject(cropobj) - ) { - cropleft = canvas.getItemById('crop').get('left'); - croptop = canvas.getItemById('crop').get('top'); - cropscalex = canvas.getItemById('crop').get('scaleX'); - cropscaley = canvas.getItemById('crop').get('scaleY'); - } + updateCropBounds(); crop(canvas.getItemById('cropped')); } }); // An object is being rotated in the canvas canvas.on('object:rotating', function (e) { - if (e.e.shiftKey) { - canvas.getActiveObject().snapAngle = 15; - } else { - canvas.getActiveObject().snapAngle = 0; + if (canvas.getActiveObject()) { + canvas.getActiveObject().snapAngle = e.e.shiftKey ? 15 : 0; } e.target.hasControls = false; }); @@ -82,8 +128,10 @@ $(document).ready(function () { canvas.on('object:modified', function (e) { e.target.hasControls = true; if (!editinggroup && !cropping) { - canvas.getActiveObject().lockMovementX = false; - canvas.getActiveObject().lockMovementY = false; + if (canvas.getActiveObject()) { + canvas.getActiveObject().lockMovementX = false; + canvas.getActiveObject().lockMovementY = false; + } canvas.renderAll(); if (e.target.type == 'activeSelection') { const tempselection = canvas.getActiveObject(); @@ -193,8 +241,12 @@ $(document).ready(function () { this.setViewportTransform(this.viewportTransform); this.isDragging = false; this.selection = true; - line_h.opacity = 0; - line_v.opacity = 0; + if (line_h) { + line_h.opacity = 0; + } + if (line_v) { + line_v.opacity = 0; + } }); // Detect mouse over canvas (for dragging objects from the library) @@ -214,7 +266,9 @@ $(document).ready(function () { canvas.on('mouse:out', function (e) { overCanvas = false; if (wip) { - e.target.hasControls = true; + if (e.target) { + e.target.hasControls = true; + } canvas.discardActiveObject(); wip = false; canvas.renderAll(); @@ -320,13 +374,16 @@ $(document).ready(function () { } }, 1000); } - // Redo - if (e.which === 90 && (e.ctrlKey || e.metaKey) && e.shiftKey) { - undoRedo(redo, undo, redoarr, undoarr); - } - // Undo + // Redo / undo (shift decides which; never both in one keypress) if (e.which === 90 && (e.ctrlKey || e.metaKey)) { - undoRedo(undo, redo, undoarr, redoarr); + e.preventDefault(); + if (e.shiftKey) { + if (redo.length >= 1) { + undoRedo(redo, undo, redoarr, undoarr); + } + } else if (undo.length >= 1) { + undoRedo(undo, redo, undoarr, redoarr); + } } // Duplicate object if (e.which === 68 && (e.ctrlKey || e.metaKey)) { @@ -353,51 +410,30 @@ $(document).ready(function () { if (e.keyCode === 13 && editingproject) { saveProjectName(); } - // Left arrow key (move object to the left) - if (e.keyCode === 37 && canvas.getActiveObject()) { - var obj = canvas.getActiveObject(); - var step = 2; + // Arrow keys nudge the selection + if ( + e.keyCode >= 37 && + e.keyCode <= 40 && + canvas.getActiveObject() && + !canvas.getActiveObject().isEditing && + !focus && + !editinglayer && + !editingproject + ) { + const obj = canvas.getActiveObject(); // Bigger step if shift is down - if (e.shiftKey) { - step = 7; + const step = e.shiftKey ? 7 : 2; + if (e.keyCode === 37) { + obj.left = obj.left - step; + } else if (e.keyCode === 38) { + obj.top = obj.top - step; + } else if (e.keyCode === 39) { + obj.left = obj.left + step; + } else { + obj.top = obj.top + step; } - obj.left = obj.left - step; - canvas.renderAll(); - autoKeyframe(obj, { action: 'drag' }, false); - } - // Up arrow key (move object up) - if (e.keyCode === 38 && canvas.getActiveObject()) { - var obj = canvas.getActiveObject(); - var step = 2; - // Bigger step if shift is down - if (e.shiftKey) { - step = 7; - } - obj.top = obj.top - step; - canvas.renderAll(); - autoKeyframe(obj, { action: 'drag' }, false); - } - // Right arrow key (move object to the right) - if (e.keyCode === 39 && canvas.getActiveObject()) { - var obj = canvas.getActiveObject(); - var step = 2; - // Bigger step if shift is down - if (e.shiftKey) { - step = 7; - } - obj.left = obj.left + step; - canvas.renderAll(); - autoKeyframe(obj, { action: 'drag' }, false); - } - // Down arrow key (move object down) - if (e.keyCode === 40 && canvas.getActiveObject()) { - var obj = canvas.getActiveObject(); - var step = 2; - // Bigger step if shift is down - if (e.shiftKey) { - step = 7; - } - obj.top = obj.top + step; + // Without this the selection box stays where the object used to be + obj.setCoords(); canvas.renderAll(); autoKeyframe(obj, { action: 'drag' }, false); } @@ -406,7 +442,7 @@ $(document).ready(function () { if ( e.keyCode === 221 && canvas.getActiveObjects() && - e.metaKey + (e.metaKey || e.ctrlKey) ) { if (canvas.getActiveObjects().length == 1) { var obj = canvas.getActiveObject(); @@ -434,7 +470,7 @@ $(document).ready(function () { if ( e.keyCode === 219 && canvas.getActiveObjects() && - e.metaKey + (e.metaKey || e.ctrlKey) ) { if (canvas.getActiveObjects().length == 1) { var obj = canvas.getActiveObject(); @@ -545,25 +581,24 @@ $(document).ready(function () { // Copy event window.addEventListener('copy', function (e) { + // Selecting keyframes clears the canvas selection, so there is often no + // active object at all here - never dereference it unguarded. + const activeObject = canvas.getActiveObject(); + if (activeObject && activeObject.isEditing) { + return; + } // Copy selected object - if ( - canvas.getActiveObject() && - shiftkeys.length == 0 && - !canvas.getActiveObject().isEditing - ) { + if (activeObject && shiftkeys.length == 0) { var emptyInp = document.getElementById('emptyInput'); emptyInp.select(); emptyInp.focus(); setTimeout(function () { document.execCommand('copy'); }, 0); - clipboard = canvas.getActiveObject(); + clipboard = activeObject; cliptype = 'object'; // Copy selected keyframe(s) - } else if ( - shiftkeys.length > 0 && - !canvas.getActiveObject().isEditing - ) { + } else if (shiftkeys.length > 0) { var emptyInp = document.getElementById('emptyInput'); emptyInp.select(); emptyInp.focus(); @@ -580,7 +615,9 @@ $(document).ready(function () { e.name == drag.attr('data-property') ); }); - clipboard.push(keyarr[0]); + if (keyarr.length > 0) { + clipboard.push(keyarr[0]); + } }); cliptype = 'keyframe'; } @@ -598,7 +635,9 @@ $(document).ready(function () { } else { for (var i = 0; i < imgs.length; i++) { if (imgs[i].type.indexOf('image') == -1) continue; - var imgObj = imgs[i].getAsFile(); + // `let` so each async thumbnail callback keeps its own file + let imgObj = imgs[i].getAsFile(); + if (!imgObj) continue; if (imgObj.size / 1024 / 1024 <= 10) { createThumbnail(imgObj, 250).then(function (data) { saveFile( @@ -803,27 +842,7 @@ $(document).ready(function () { syncScrollHoz($('#timeline'), $('#seekarea')); // Initialize layer sorting - sortable('#layer-inner-list', { - customDragImage: (draggedElement, elementOffset, event) => { - return { - element: document.getElementById('nothing'), - posX: event.pageX - elementOffset.left, - posY: event.pageY - elementOffset.top, - }; - }, - })[0].addEventListener('sortstop', function (e) { - const id = $(e.detail.item).attr('data-object'); - const previd = $(e.detail.item).prev().attr('data-object'); - if ($('.sortable-dragging').length == 1) { - $('.sortable-dragging').remove(); - if (previd == undefined) { - $('#inner-timeline').prepend($('#' + id)); - } else { - $('#' + id).insertAfter($('#' + previd)); - } - orderLayers(); - } - }); + initLayerSortable(); // Initialize dropdown for keyframe easing $('#easing select').niceSelect(); @@ -936,8 +955,8 @@ $(document).ready(function () { onmove: function (x) { if (canvas.getActiveObject()) { var obj = canvas.getActiveObject(); - if (obj.filters.find((x) => x.type == 'RemoveColor')) { - obj.filters.find((x) => x.type == 'RemoveColor').distance = + if (obj.filters.find((i) => i.type == 'RemoveColor')) { + obj.filters.find((i) => i.type == 'RemoveColor').distance = x / 100; } obj.applyFilters(); @@ -964,8 +983,8 @@ $(document).ready(function () { onmove: function (x) { if (canvas.getActiveObject()) { var obj = canvas.getActiveObject(); - if (obj.filters.find((x) => x.type == 'Noise')) { - obj.filters.find((x) => x.type == 'Noise').noise = x; + if (obj.filters.find((i) => i.type == 'Noise')) { + obj.filters.find((i) => i.type == 'Noise').noise = x; } else { obj.filters.push( new f.Noise({ @@ -997,8 +1016,8 @@ $(document).ready(function () { onmove: function (x) { if (canvas.getActiveObject()) { var obj = canvas.getActiveObject(); - if (obj.filters.find((x) => x.type == 'Blur')) { - obj.filters.find((x) => x.type == 'Blur').blur = x / 100; + if (obj.filters.find((i) => i.type == 'Blur')) { + obj.filters.find((i) => i.type == 'Blur').blur = x / 100; } else { obj.filters.push( new f.Blur({ @@ -1006,9 +1025,9 @@ $(document).ready(function () { }) ); } + obj.applyFilters(); + canvas.renderAll(); } - obj.applyFilters(); - canvas.renderAll(); }, onfinish: function (x) { save(); diff --git a/src/js/functions.js b/src/js/functions.js index b221d2d..b0596fb 100644 --- a/src/js/functions.js +++ b/src/js/functions.js @@ -26,7 +26,7 @@ function updateSelection(e) { $(".layer[data-object='" + object.get('id') + "']").addClass( 'layer-selected' ); - if (e.e != undefined) { + if (e.e != undefined && $('.layer-selected').length > 0) { document .getElementsByClassName('layer-selected')[0] .scrollIntoView(); @@ -42,7 +42,7 @@ function updateSelection(e) { $(".layer[data-object='" + e.target.get('id') + "']").addClass( 'layer-selected' ); - if (e.e != undefined) { + if (e.e != undefined && $('.layer-selected').length > 0) { document .getElementsByClassName('layer-selected')[0] .scrollIntoView(); @@ -244,7 +244,6 @@ function group() { absolutePositioned: true, inGroup: false, strokeDashArray: false, - objectCaching: true, shadow: { color: 'black', offsetX: 0, @@ -257,18 +256,7 @@ function group() { canvas.renderAll(); newLayer(newgroup); canvas.setActiveObject(newgroup); - keyframes.sort(function (a, b) { - if (a.id.indexOf('Group') >= 0 && b.id.indexOf('Group') == -1) { - return 1; - } else if ( - b.id.indexOf('Group') >= 0 && - a.id.indexOf('Group') == -1 - ) { - return -1; - } else { - return 0; - } - }); + sortKeyframes(); save(); } $(document).on('click', '#group-objects', group); @@ -348,11 +336,19 @@ $(document).on('click', '#ungroup-objects', function () { function reGroup(id) { var group = []; var objects = []; - groups - .find((x) => x.id == id) - .objects.forEach(function (object) { - objects.push(canvas.getItemById(object)); - }); + const entry = groups.find((x) => x.id == id); + if (!entry) { + return; + } + entry.objects.forEach(function (object) { + const item = canvas.getItemById(object); + if (item) { + objects.push(item); + } + }); + if (objects.length == 0) { + return; + } var activeselection = new fabric.ActiveSelection(objects); var newgroup = activeselection.toGroup(); newgroup.set({ @@ -364,14 +360,20 @@ function reGroup(id) { } // Keep record canvas up to date -function updateRecordCanvas() { +async function updateRecordCanvas() { canvasrecord.setWidth(artboard.width); canvasrecord.setHeight(artboard.height); canvasrecord.width = artboard.width; canvasrecord.height = artboard.height; canvas.clipPath = null; - objects.forEach(async function (object) { + // Sequential, not forEach(async): the snapshot below must not be taken + // while filters are still being stripped off the objects. + for (const object of objects) { var obj = canvas.getItemById(object.id); + if (!obj) { + object.filters = []; + continue; + } if (obj.filters) { if (obj.filters.length > 0) { object.filters = []; @@ -449,7 +451,7 @@ function updateRecordCanvas() { } else { object.filters = []; } - }); + } const canvassave = canvas.toJSON([ 'volume', 'audioSrc', @@ -536,10 +538,75 @@ function updateRecordCanvas() { }); } +// Frame rate the user picked in the download modal, used by every export path +const EXPORT_FPS_DEFAULT = 30; + +function getExportFramerate() { + const fps = parseInt($('#framerate').val(), 10); + if (!(fps > 0)) { + return EXPORT_FPS_DEFAULT; + } + return Math.min(120, fps); +} + +// A still image has no frame rate +$('input[name=radio]').on('change', function () { + $('#framerate-row').toggle( + $('input[name=radio]:checked').val() != 'image' + ); +}); + +// Put the editor back into a usable state once a render has finished (or failed) +function resetRecordingUI() { + recording = false; + currenttime = 0; + animate(false, 0); + $('#seekbar').offset({ + left: + offset_left + + $('#inner-timeline').offset().left + + currenttime / timelinetime, + }); + canvas.renderAll(); + resizeCanvas(); + if (background_audio != false) { + background_audio.pause(); + background_audio = new Audio(background_audio.src); + } + $('#download-real').html('Download'); + $('#download-real').removeClass('downloading'); + updateRecordCanvas(); +} + +// Hand a finished WebM recording to the user, converting it first if the +// chosen format is not webm. +function deliverRecording(blob) { + $('#download-real').html('Downloading...'); + const format = $('input[name=radio]:checked').val(); + if (format == 'mp4' || format == 'gif') { + convertStreams(blob, format); + return; + } + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = 'video.webm'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // Give the browser a tick to start the download before releasing the blob + window.setTimeout(function () { + URL.revokeObjectURL(url); + }, 60000); + resetRecordingUI(); +} + // Download recording function downloadRecording(chunks) { $('#download-real').html('Downloading...'); - if ($('input[name=radio]:checked').val() == 'webm') { + const format = $('input[name=radio]:checked').val(); + if (format == 'webm') { var url = URL.createObjectURL( new Blob(chunks, { type: 'video/webm', @@ -551,24 +618,21 @@ function downloadRecording(chunks) { a.download = 'video.webm'; document.body.appendChild(a); a.click(); - recording = false; - currenttime = 0; - animate(false, 0); - $('#seekbar').offset({ - left: - offset_left + - $('#inner-timeline').offset().left + - currenttime / timelinetime, - }); - canvas.renderAll(); - resizeCanvas(); - $('#download-real').html('Download'); - $('#download-real').removeClass('downloading'); - updateRecordCanvas(); - } else if ($('input[name=radio]:checked').val() == 'mp4') { - type = 'video/mp4'; + document.body.removeChild(a); + // Give the browser a tick to start the download before releasing the blob + window.setTimeout(function () { + URL.revokeObjectURL(url); + }, 60000); + resetRecordingUI(); + } else if (format == 'mp4' || format == 'gif') { + // Both are transcoded from the captured webm by the ffmpeg worker + convertStreams( + new Blob(chunks, { type: 'video/webm' }), + format + ); } else { - convertStreams(new Blob(chunks, { type: 'video/webm' }), 'gif'); + console.warn('Unknown download format: ' + format); + resetRecordingUI(); } } @@ -653,8 +717,19 @@ function save() { $('#redo').removeClass('history-active'); } - updateRecordCanvas(); - autoSave(); + schedulePersist(); +} + +// Rebuilding the record canvas and writing to IndexedDB are both expensive +// and used to run on every single edit (including every keystroke). Coalesce +// them; record() awaits updateRecordCanvas() directly when it needs it fresh. +var persistTimer; +function schedulePersist() { + window.clearTimeout(persistTimer); + persistTimer = window.setTimeout(function () { + updateRecordCanvas(); + autoSave(); + }, 400); } // Duplicate object @@ -664,6 +739,8 @@ function copyObject() { if (clipboard.type == 'activeSelection') { clipboard._objects.forEach(function (clone) { clone.clone(function (cloned) { + // layer_count only advances inside newLayer, so read it after + // the clone resolves or every copy shares one id. cloned.set({ id: 'Shape' + layer_count, }); @@ -899,12 +976,14 @@ function undoRedo(newState, saveState, newArrState, saveArrState) { canvas.clipPath = null; canvas.loadFromJSON(state, function () { canvas.clipPath = artboard; - canvas.getItemById('line_h').set({ opacity: 0 }); - canvas.getItemById('line_v').set({ opacity: 0 }); + hideGuides(canvas); canvas.renderAll(); $('.object-props').remove(); $('.layer').remove(); objects.forEach(function (object) { + if (!canvas.getItemById(object.id)) { + return; + } replaceSource(canvas.getItemById(object.id), canvas); renderLayer(canvas.getItemById(object.id)); props.forEach(function (prop) { @@ -924,6 +1003,9 @@ function undoRedo(newState, saveState, newArrState, saveArrState) { }); }); keyframes.forEach(function (keyframe) { + if (!canvas.getItemById(keyframe.id)) { + return; + } if ( keyframe.name != 'top' && keyframe.name != 'scaleY' && @@ -963,7 +1045,7 @@ $(document).on('click', '#undo', function () { } }); $(document).on('click', '#redo', function () { - if (undo.length >= 1) { + if (redo.length >= 1) { undoRedo(redo, undo, redoarr, undoarr); } }); @@ -1048,7 +1130,7 @@ function keyframeChanges(object, type, id, selection) { 'height', object, currenttime, - object.get('width'), + object.get('height'), true ); if (selection) { @@ -1193,6 +1275,43 @@ function pause() { $('#play-button').attr('src', 'assets/play-button.svg'); } +// Write an object's stored default for a property, creating the entry if the +// object was serialized before that property existed. +function setDefaultValue(object, prop, value) { + const entry = objects.find((x) => x.id == object.get('id')); + if (!entry) { + return; + } + if (!entry.defaults) { + entry.defaults = []; + } + const def = entry.defaults.find((x) => x.name == prop); + if (def) { + def.value = value; + } else { + entry.defaults.push({ name: prop, value: value }); + } +} + +// Read an object's stored default for a property (undefined when absent) +function getDefaultValue(id, prop) { + const entry = objects.find((x) => x.id == id); + if (!entry || !entry.defaults) { + return undefined; + } + const def = entry.defaults.find((x) => x.name == prop); + return def ? def.value : undefined; +} + +// Read a property that may be a "shadow.*" path. +// fabric's get() is flat, so object.get("shadow.blur") is always undefined. +function getPropValue(object, prop) { + if (typeof prop === 'string' && prop.indexOf('shadow.') === 0) { + return object.shadow ? object.shadow[prop.slice(7)] : undefined; + } + return object.get(prop); +} + // Set object value (while animating) function setObjectValue(prop, object, value, inst) { if (object.get('type') != 'group') { @@ -1221,8 +1340,6 @@ function setObjectValue(prop, object, value, inst) { object.shadow.offsetX = value; } else if (prop == 'shadow.offsetY') { object.shadow.offsetY = value; - } else if (prop == 'shadow.blur') { - object.shadow.blur = value; } else if (object.get('type') != 'group') { object.set(prop, value); } else if (prop != 'width') { @@ -1232,34 +1349,68 @@ function setObjectValue(prop, object, value, inst) { inst.renderAll(); } -// Find last keyframe in time from same object & property -function lastKeyframe(keyframe, index) { - var temparr = keyframes.slice(); - temparr.sort(function (a, b) { - return a.t - b.t; - }); - temparr.length = temparr.findIndex((x) => x === keyframe); - temparr.reverse(); - if (temparr.length == 0) { - return false; - } else { - for (var i = 0; i < temparr.length; i++) { - if ( - temparr[i].id == keyframe.id && - temparr[i].name == keyframe.name - ) { - return temparr[i]; - break; - } else if (i == temparr.length - 1) { - return false; - } +// Group keyframes by "id|name", each group sorted by time. +// Built once per rendered frame: without it every keyframe lookup sorted a +// copy of the whole keyframe list, making playback O(n^2 log n). +function buildKeyframeIndex() { + const idx = new Map(); + keyframes.forEach(function (keyframe) { + const key = keyframe.id + '|' + keyframe.name; + let arr = idx.get(key); + if (!arr) { + arr = []; + idx.set(key, arr); } + arr.push(keyframe); + }); + idx.forEach(function (arr) { + arr.sort(function (a, b) { + return a.t - b.t; + }); + }); + return idx; +} + +// All keyframes of one object/property, sorted by time +function keyframeSiblings(keyframe, idx) { + if (idx) { + return idx.get(keyframe.id + '|' + keyframe.name) || []; } + return keyframes + .filter(function (e) { + return e.id == keyframe.id && e.name == keyframe.name; + }) + .sort(function (a, b) { + return a.t - b.t; + }); +} + +// Find last keyframe in time from same object & property +function lastKeyframe(keyframe, idx) { + const arr = keyframeSiblings(keyframe, idx); + const pos = arr.indexOf(keyframe); + if (pos <= 0) { + return false; + } + return arr[pos - 1]; +} + +// Find next keyframe in time from same object & property +function nextKeyframe(keyframe, idx) { + const arr = keyframeSiblings(keyframe, idx); + const pos = arr.indexOf(keyframe); + if (pos == -1 || pos == arr.length - 1) { + return false; + } + return arr[pos + 1]; } // Check whether any keyframe exists for a certain property -function checkAnyKeyframe(id, prop, inst) { +function checkAnyKeyframe(id, prop, inst, idx) { const object = inst.getItemById(id); + if (!object) { + return false; + } if (object.get('assetType') == 'audio') { return false; } @@ -1279,13 +1430,14 @@ function checkAnyKeyframe(id, prop, inst) { ) { return false; } - const keyarr2 = $.grep(keyframes, function (e) { - return e.id == id && e.name == prop; - }); - if (keyarr2.length == 0) { - const value = objects - .find((x) => x.id == id) - .defaults.find((x) => x.name == prop).value; + const hasKeyframe = idx + ? idx.has(id + '|' + prop) + : keyframes.some((e) => e.id == id && e.name == prop); + if (!hasKeyframe) { + const value = getDefaultValue(id, prop); + if (value === undefined) { + return false; + } setObjectValue(prop, object, value, inst); } } @@ -1299,23 +1451,18 @@ function isDomElem(el) { // Play videos when seeking/playing async function playVideos(time) { - objects.forEach(async function (object) { - var object = canvas.getItemById(object.id); - if (object == null) { + objects.forEach(async function (entry) { + var inst = recording ? canvasrecord : canvas; + var object = inst.getItemById(entry.id); + var p_keyframe = p_keyframes.find((x) => x.id == entry.id); + if (object == null || !p_keyframe) { return false; } - var inst = canvas; var start = false; - if (recording) { - object = canvasrecord.getItemById(object.id); - inst = canvasrecord; - } if ( object.get('id').indexOf('Video') >= 0 && - p_keyframes.find((x) => x.id == object.id).trimstart + - p_keyframes.find((x) => x.id == object.id).start <= - time && - p_keyframes.find((x) => x.id == object.id).end >= time + p_keyframe.trimstart + p_keyframe.start <= time && + p_keyframe.end >= time ) { var tempfilters = object.filters; if (object.filters.length > 0) { @@ -1334,9 +1481,7 @@ async function playVideos(time) { if ($(object.getElement())[0].paused == true) { $(object.getElement())[0].currentTime = parseFloat( ( - (time - - p_keyframes.find((x) => x.id == object.id).start + - p_keyframes.find((x) => x.id == object.id).trimstart) / + (time - p_keyframe.start + p_keyframe.trimstart) / 1000 ).toFixed(2) ); @@ -1394,10 +1539,7 @@ async function playVideos(time) { if (paused) { $(object.getElement())[0].currentTime = parseFloat( ( - (time - - p_keyframes.find((x) => x.id == object.id).start + - p_keyframes.find((x) => x.id == object.id) - .trimstart) / + (time - p_keyframe.start + p_keyframe.trimstart) / 1000 ).toFixed(2) ); @@ -1424,10 +1566,11 @@ async function playVideos(time) { // Play background audio function playAudio(time) { - objects.forEach(async function (object) { + objects.forEach(function (object) { var start = false; var obj = canvas.getItemById(object.id); - if (obj.get('assetType') == 'audio') { + var p_keyframe = p_keyframes.find((x) => x.id == object.id); + if (obj && p_keyframe && obj.get('assetType') == 'audio') { var flag = false; var animation = { value: 0, @@ -1439,20 +1582,20 @@ function playAudio(time) { duration: duration, easing: 'linear', autoplay: true, - update: async function () { - if (start && play && !paused) { + update: function () { + // `paused` is the only playback flag here; `play` used to be read + // as a variable but resolves to the global play() function, so the + // condition was always true. + if (start && !paused) { if ( !flag && - p_keyframes.find((x) => x.id == object.id).start <= - currenttime && - p_keyframes.find((x) => x.id == object.id).end >= - currenttime + p_keyframe.start <= currenttime && + p_keyframe.end >= currenttime ) { if (obj.get('src')) { obj.get('src').currentTime = - (p_keyframes.find((x) => x.id == object.id) - .trimstart - - p_keyframes.find((x) => x.id == object.id).start + + (p_keyframe.trimstart - + p_keyframe.start + currenttime) / 1000; obj.get('src').volume = obj.get('volume'); @@ -1464,19 +1607,16 @@ function playAudio(time) { audio.volume = obj.get('volume'); audio.crossOrigin = 'anonymous'; audio.currentTime = - (p_keyframes.find((x) => x.id == object.id) - .trimstart - - p_keyframes.find((x) => x.id == object.id).start + + (p_keyframe.trimstart - + p_keyframe.start + currenttime) / 1000; audio.play(); flag = true; } } else if ( - p_keyframes.find((x) => x.id == object.id).start >= - currenttime || - p_keyframes.find((x) => x.id == object.id).end <= - currenttime + p_keyframe.start >= currenttime || + p_keyframe.end <= currenttime ) { if (obj.get('src')) { obj.get('src').pause(); @@ -1502,6 +1642,7 @@ async function recordAnimate(time) { anime.speed = 1; //return new Promise(function(resolve){ var inst = canvasrecord; + const idx = buildKeyframeIndex(); if (animatedtext.length > 0) { animatedtext.forEach(function (text) { text.seek(time, inst); @@ -1515,33 +1656,23 @@ async function recordAnimate(time) { reGroup(keyframe.id); } const object = inst.getItemById(keyframe.id); - if (!object) { + const p_keyframe = p_keyframes.find((x) => x.id == keyframe.id); + if (!object || !p_keyframe) { return; } - if ( - time < - p_keyframes.find((x) => x.id == keyframe.id).trimstart + - p_keyframes.find((x) => x.id == keyframe.id).start - ) { + if (time < p_keyframe.trimstart + p_keyframe.start) { object.set('visible', false); inst.renderAll(); - } else if ( - time > p_keyframes.find((x) => x.id == keyframe.id).end || - time > duration - ) { + } else if (time > p_keyframe.end || time > duration) { object.set('visible', false); inst.renderAll(); } else { object.set('visible', true); inst.renderAll(); } - if ( - time >= - p_keyframes.find((x) => x.id == keyframe.id).trimstart + - p_keyframes.find((x) => x.id == keyframe.id).start - ) { + if (time >= p_keyframe.trimstart + p_keyframe.start) { props.forEach(function (prop) { - checkAnyKeyframe(keyframe.id, prop, inst); + checkAnyKeyframe(keyframe.id, prop, inst, idx); }); } } @@ -1574,8 +1705,6 @@ async function recordAnimate(time) { object.shadow.offsetX = value; } else if (prop == 'shadow.offsetY') { object.shadow.offsetY = value; - } else if (prop == 'shadow.blur') { - object.shadow.blur = value; } else if (object.get('type') != 'group') { object.set(prop, value); } else if (prop != 'width') { @@ -1585,30 +1714,6 @@ async function recordAnimate(time) { inst.renderAll(); } - // Find next keyframe in time from same object & property - function nextKeyframe(keyframe, index) { - var temparr = keyframes.slice(); - temparr.sort(function (a, b) { - return a.t - b.t; - }); - temparr.splice(0, temparr.findIndex((x) => x === keyframe) + 1); - if (temparr.length == 0) { - return false; - } else { - for (var i = 0; i < temparr.length; i++) { - if ( - temparr[i].id == keyframe.id && - temparr[i].name == keyframe.name - ) { - return temparr[i]; - break; - } else if (i == temparr.length - 1) { - return false; - } - } - } - } - var object = canvasrecord.getItemById(keyframe.id); if (!object) { return; @@ -1623,12 +1728,13 @@ async function recordAnimate(time) { var start = false; var lasttime, lastprop; // Find last keyframe in time from same object & property - var lastkey = lastKeyframe(keyframe, index); + var lastkey = lastKeyframe(keyframe, idx); if (!lastkey) { lasttime = 0; - lastprop = objects - .find((x) => x.id == keyframe.id) - .defaults.find((x) => x.name == keyframe.name).value; + lastprop = getDefaultValue(keyframe.id, keyframe.name); + if (lastprop === undefined) { + return; + } } else { lasttime = lastkey.t; lastprop = lastkey.value; @@ -1674,7 +1780,7 @@ async function recordAnimate(time) { ) { setValue(keyframe.name, object, animation.value, inst); } - } else if (keyframe.t < time && !nextKeyframe(keyframe, index)) { + } else if (keyframe.t < time && !nextKeyframe(keyframe, idx)) { var prop = keyframe.name; if (prop == 'shadow.blur') { if (object.shadow.blur != keyframe.value) { @@ -1705,37 +1811,31 @@ async function recordAnimate(time) { // Visibility has to be applied to the object on the recording canvas, // at the time of the frame being rendered (not the editor playhead). const object2 = inst.getItemById(object.id); - if (!object2) { + const p_keyframe = p_keyframes.find((x) => x.id == object.id); + if (!object2 || !p_keyframe) { return; } - if ( - time < - p_keyframes.find((x) => x.id == object.id).trimstart + - p_keyframes.find((x) => x.id == object.id).start - ) { + if (time < p_keyframe.trimstart + p_keyframe.start) { object2.set('visible', false); - } else if ( - time > p_keyframes.find((x) => x.id == object.id).end || - time > duration - ) { + } else if (time > p_keyframe.end || time > duration) { object2.set('visible', false); } else { object2.set('visible', true); } - if ( - time >= - p_keyframes.find((x) => x.id == object.id).trimstart + - p_keyframes.find((x) => x.id == object.id).start - ) { + if (time >= p_keyframe.trimstart + p_keyframe.start) { props.forEach(function (prop) { - checkAnyKeyframe(object.id, prop, inst); + checkAnyKeyframe(object.id, prop, inst, idx); }); } } }); inst.renderAll(); - playVideos(time); + // The offline renderer seeks each video layer to the exact frame time + // itself; playVideos() would fight it by starting real-time playback. + if (!offlinerender) { + playVideos(time); + } //}); } @@ -1743,52 +1843,27 @@ async function recordAnimate(time) { async function animate(play, time) { anime.speed = speed; if (!draggingPanel) { - var starttime = new Date(); var offset = time; var inst = canvas; + const idx = buildKeyframeIndex(); keyframes.forEach(function (keyframe, index) { - // Find next keyframe in time from same object & property - function nextKeyframe(keyframe, index) { - var temparr = keyframes.slice(); - temparr.sort(function (a, b) { - return a.t - b.t; - }); - temparr.splice( - 0, - temparr.findIndex((x) => x === keyframe) + 1 - ); - if (temparr.length == 0) { - return false; - } else { - for (var i = 0; i < temparr.length; i++) { - if ( - temparr[i].id == keyframe.id && - temparr[i].name == keyframe.name - ) { - return temparr[i]; - break; - } else if (i == temparr.length - 1) { - return false; - } - } - } - } // Regroup if needed (groups break to animate their children, then regroup after children have animated) if (groups.find((x) => x.id == keyframe.id)) { if (!canvas.getItemById(keyframe.id)) { reGroup(keyframe.id); } const object = canvas.getItemById(keyframe.id); - if ( - currenttime < - p_keyframes.find((x) => x.id == keyframe.id).trimstart + - p_keyframes.find((x) => x.id == keyframe.id).start - ) { + const p_keyframe = p_keyframes.find( + (x) => x.id == keyframe.id + ); + if (!object || !p_keyframe) { + return; + } + if (currenttime < p_keyframe.trimstart + p_keyframe.start) { object.set('visible', false); inst.renderAll(); } else if ( - currenttime > - p_keyframes.find((x) => x.id == keyframe.id).end || + currenttime > p_keyframe.end || currenttime > duration ) { object.set('visible', false); @@ -1797,13 +1872,9 @@ async function animate(play, time) { object.set('visible', true); inst.renderAll(); } - if ( - currenttime >= - p_keyframes.find((x) => x.id == keyframe.id).trimstart + - p_keyframes.find((x) => x.id == keyframe.id).start - ) { + if (currenttime >= p_keyframe.trimstart + p_keyframe.start) { props.forEach(function (prop) { - checkAnyKeyframe(keyframe.id, prop, inst); + checkAnyKeyframe(keyframe.id, prop, inst, idx); }); } } @@ -1845,8 +1916,6 @@ async function animate(play, time) { object.shadow.offsetX = value; } else if (prop == 'shadow.offsetY') { object.shadow.offsetY = value; - } else if (prop == 'shadow.blur') { - object.shadow.blur = value; } else if (object.get('type') != 'group') { object.set(prop, value); } else if (prop != 'width') { @@ -1856,22 +1925,28 @@ async function animate(play, time) { } var object = canvas.getItemById(keyframe.id); + var kf_p_keyframe = p_keyframes.find( + (x) => x.id == keyframe.id + ); + if (!object || !kf_p_keyframe) { + return; + } if ( keyframe.t >= time && currenttime >= - p_keyframes.find((x) => x.id == keyframe.id).trimstart + - p_keyframes.find((x) => x.id == keyframe.id).start + kf_p_keyframe.trimstart + kf_p_keyframe.start ) { var delay = 0; var start = false; var lasttime, lastprop; // Find last keyframe in time from same object & property - var lastkey = lastKeyframe(keyframe, index); + var lastkey = lastKeyframe(keyframe, idx); if (!lastkey) { lasttime = 0; - lastprop = objects - .find((x) => x.id == keyframe.id) - .defaults.find((x) => x.name == keyframe.name).value; + lastprop = getDefaultValue(keyframe.id, keyframe.name); + if (lastprop === undefined) { + return; + } } else { lasttime = lastkey.t; lastprop = lastkey.value; @@ -1900,12 +1975,8 @@ async function animate(play, time) { if (start && !paused) { if ( currenttime < - p_keyframes.find((x) => x.id == keyframe.id) - .trimstart + - p_keyframes.find((x) => x.id == keyframe.id) - .start || - currenttime > - p_keyframes.find((x) => x.id == keyframe.id).end || + kf_p_keyframe.trimstart + kf_p_keyframe.start || + currenttime > kf_p_keyframe.end || currenttime > duration ) { object.set('visible', false); @@ -1945,7 +2016,7 @@ async function animate(play, time) { } } else if ( keyframe.t < time && - !nextKeyframe(keyframe, index) + !nextKeyframe(keyframe, idx) ) { var prop = keyframe.name; if (prop == 'left' && !recording) { @@ -2007,33 +2078,28 @@ async function animate(play, time) { objects.forEach(function (object) { if (object.id.indexOf('Group') == -1) { const object2 = canvas.getItemById(object.id); - if ( - currenttime < - p_keyframes.find((x) => x.id == object.id).trimstart + - p_keyframes.find((x) => x.id == object.id).start - ) { + const p_keyframe = p_keyframes.find((x) => x.id == object.id); + if (!object2 || !p_keyframe) { + return; + } + if (currenttime < p_keyframe.trimstart + p_keyframe.start) { object2.set('visible', false); } else if ( - currenttime > - p_keyframes.find((x) => x.id == object.id).end || + currenttime > p_keyframe.end || currenttime > duration ) { object2.set('visible', false); } else { object2.set('visible', true); } - if ( - currenttime >= - p_keyframes.find((x) => x.id == object.id).trimstart + - p_keyframes.find((x) => x.id == object.id).start - ) { + if (currenttime >= p_keyframe.trimstart + p_keyframe.start) { props.forEach(function (prop) { - checkAnyKeyframe(object.id, prop, inst); + checkAnyKeyframe(object.id, prop, inst, idx); }); } } var obj = canvas.getItemById(object.id); - if (obj.type == 'lottie') { + if (obj && obj.type == 'lottie') { obj.goToSeconds(currenttime); inst.renderAll(); } @@ -2073,16 +2139,19 @@ async function animate(play, time) { objects.forEach(function (object) { if (object.id.indexOf('Group') == -1) { const object2 = inst.getItemById(object.id); + const p_keyframe = p_keyframes.find( + (x) => x.id == object.id + ); + if (!object2 || !p_keyframe) { + return; + } if ( currenttime < - p_keyframes.find((x) => x.id == object.id) - .trimstart + - p_keyframes.find((x) => x.id == object.id).start + p_keyframe.trimstart + p_keyframe.start ) { object2.set('visible', false); } else if ( - currenttime > - p_keyframes.find((x) => x.id == object.id).end || + currenttime > p_keyframe.end || currenttime > duration ) { object2.set('visible', false); @@ -2091,17 +2160,15 @@ async function animate(play, time) { } if ( currenttime >= - p_keyframes.find((x) => x.id == object.id) - .trimstart + - p_keyframes.find((x) => x.id == object.id).start + p_keyframe.trimstart + p_keyframe.start ) { props.forEach(function (prop) { - checkAnyKeyframe(object.id, prop, inst); + checkAnyKeyframe(object.id, prop, inst, idx); }); } } var obj = canvas.getItemById(object.id); - if (obj.type == 'lottie') { + if (obj && obj.type == 'lottie') { obj.goToSeconds(currenttime); inst.renderAll(); } @@ -2129,64 +2196,53 @@ async function animate(play, time) { } } -// Render a keyframe +// Keep group keyframes last so children animate before their group regroups +function sortKeyframes() { + keyframes.sort(function (a, b) { + if (a.id.indexOf('Group') >= 0 && b.id.indexOf('Group') == -1) { + return 1; + } else if ( + b.id.indexOf('Group') >= 0 && + a.id.indexOf('Group') == -1 + ) { + return -1; + } else { + return 0; + } + }); +} + +// Render a keyframe. +// data-time is ALWAYS the absolute timeline time, because every lookup keys +// off it. The CSS offset is relative, since the row it lives in is already +// shifted by the layer start. function renderKeyframe(object, prop, time) { - const color = objects.find((x) => x.id == object.id).color; - if (prop == 'shadow.color') { - if ( - $('#' + object.get('id')) - .find('.shadowcolor') - .is(':visible') - ) { - time = - time - - parseFloat( - p_keyframes.find((x) => x.id == object.get('id')).start - ); - } - $('#' + object.get('id')) - .find('.shadowcolor') - .prepend( - "
" - ); - $('#' + object.get('id')) - .find('.shadowcolor') - .find("[data-time='" + time + "']") - .css({ left: time / timelinetime, background: color }); - } else { - if ( - $('#' + object.get('id')) - .find('.' + prop) - .is(':visible') - ) { - time = - time - - parseFloat( - p_keyframes.find((x) => x.id == object.get('id')).start - ); - } - $('#' + object.get('id')) - .find('.' + prop) - .prepend( - "
" - ); - $('#' + object.get('id')) - .find('.' + prop) - .find("[data-time='" + time + "']") - .css({ left: time / timelinetime, background: color }); + const entry = objects.find((x) => x.id == object.get('id')); + const p_keyframe = p_keyframes.find( + (x) => x.id == object.get('id') + ); + if (!entry || !p_keyframe) { + return; } + const color = entry.color; + const rowclass = prop == 'shadow.color' ? 'shadowcolor' : prop; + const row = $('#' + object.get('id')).find('.' + rowclass); + if (row.length == 0) { + return; + } + const displaytime = time - parseFloat(p_keyframe.start); + row.prepend( + "
" + ); + row + .find("[data-time='" + time + "'][data-property='" + prop + "']") + .css({ left: displaytime / timelinetime, background: color }); } // Create a keyframe @@ -2210,19 +2266,19 @@ function newKeyframe(property, object, time, value, render) { }); if (keyarr2.length == 0) { if (property == 'left') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == property).value = - object.get(property) - artboard.get('left'); + setDefaultValue( + object, + property, + object.get(property) - artboard.get('left') + ); } else if (property == 'top') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == property).value = - object.get(property) - artboard.get('top'); + setDefaultValue( + object, + property, + object.get(property) - artboard.get('top') + ); } else { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == property).value = value; + setDefaultValue(object, property, value); } } if (keyarr.length == 0) { @@ -2266,21 +2322,7 @@ function newKeyframe(property, object, time, value, render) { ) { renderKeyframe(object, property, time); } - keyframes.sort(function (a, b) { - if ( - a.id.indexOf('Group') >= 0 && - b.id.indexOf('Group') == -1 - ) { - return 1; - } else if ( - b.id.indexOf('Group') >= 0 && - a.id.indexOf('Group') == -1 - ) { - return -1; - } else { - return 0; - } - }); + sortKeyframes(); } else if (render) { if ( property != 'top' && @@ -2308,19 +2350,19 @@ function newKeyframe(property, object, time, value, render) { } } else { if (property == 'left') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == property).value = - object.get(property) - artboard.get('left'); + setDefaultValue( + object, + property, + object.get(property) - artboard.get('left') + ); } else if (property == 'top') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == property).value = - object.get(property) - artboard.get('top'); + setDefaultValue( + object, + property, + object.get(property) - artboard.get('top') + ); } else { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == property).value = value; + setDefaultValue(object, property, value); } } } @@ -2368,32 +2410,33 @@ function manualKeyframe() { ); } else if (prop == 'shadow') { prop = 'shadow.color'; + // fabric's get() is not a path getter - read the shadow object directly newKeyframe( 'shadow.opacity', object, currenttime, - object.get('shadow.opacity'), + object.shadow.opacity, true ); newKeyframe( 'shadow.offsetX', object, currenttime, - object.get('shadow.offsetX'), + object.shadow.offsetX, true ); newKeyframe( 'shadow.offsetY', object, currenttime, - object.get('shadow.offsetY'), + object.shadow.offsetY, true ); newKeyframe( 'shadow.blur', object, currenttime, - object.get('shadow.blur'), + object.shadow.blur, true ); } else if (prop == 'text') { @@ -2406,7 +2449,7 @@ function manualKeyframe() { true ); } - newKeyframe(prop, object, currenttime, object.get(prop), true); + newKeyframe(prop, object, currenttime, getPropValue(object, prop), true); save(); } $(document).on('click', '.property-keyframe', manualKeyframe); @@ -2526,14 +2569,8 @@ function animateProp(prop, object) { true ); newKeyframe('top', object, currenttime, object.get('top'), true); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'left').value = - object.get('left') - artboard.get('left'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'top').value = - object.get('top') - artboard.get('top'); + setDefaultValue(object, 'left', object.get('left') - artboard.get('left')); + setDefaultValue(object, 'top', object.get('top') - artboard.get('top')); } else if (prop == 'scaleX') { newKeyframe( 'scaleY', @@ -2565,18 +2602,9 @@ function animateProp(prop, object) { objects .find((x) => x.id == object.get('id')) .animate.push('height'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'height').value = - object.get('height'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'width').value = - object.get('width'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'scaleY').value = - object.get('scaleY'); + setDefaultValue(object, 'height', object.get('height')); + setDefaultValue(object, 'width', object.get('width')); + setDefaultValue(object, 'scaleY', object.get('scaleY')); } else if (prop == 'strokeWidth') { newKeyframe( 'stroke', @@ -2585,10 +2613,7 @@ function animateProp(prop, object) { object.get('stroke'), true ); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'stroke').value = - object.get('stroke'); + setDefaultValue(object, 'stroke', object.get('stroke')); objects .find((x) => x.id == object.get('id')) .animate.push('stroke'); @@ -2628,26 +2653,11 @@ function animateProp(prop, object) { object.shadow.blur, true ); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.color').value = - object.get('shadow.color'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.opacity').value = - object.get('shadow.opacity'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.offsetX').value = - object.get('shadow.offsetX'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.offsetY').value = - object.get('shadow.offsetY'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.blur').value = - object.get('shadow.blur'); + setDefaultValue(object, 'shadow.color', object.shadow.color); + setDefaultValue(object, 'shadow.opacity', object.shadow.opacity); + setDefaultValue(object, 'shadow.offsetX', object.shadow.offsetX); + setDefaultValue(object, 'shadow.offsetY', object.shadow.offsetY); + setDefaultValue(object, 'shadow.blur', object.shadow.blur); objects .find((x) => x.id == object.get('id')) .animate.push('shadow.opacity'); @@ -2668,10 +2678,7 @@ function animateProp(prop, object) { object.get('lineHeight'), true ); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'lineHeight').value = - object.get('lineHeight'); + setDefaultValue(object, 'lineHeight', object.get('lineHeight')); objects .find((x) => x.id == object.get('id')) .animate.push('lineHeight'); @@ -2680,9 +2687,7 @@ function animateProp(prop, object) { // Exception if (prop != 'left' && prop != 'shadow.color') { newKeyframe(prop, object, currenttime, object.get(prop), true); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == prop).value = object.get(prop); + setDefaultValue(object, prop, object.get(prop)); } } @@ -2704,27 +2709,12 @@ function freezeProp(prop, object) { keyframes = $.grep(keyframes, function (e) { return e.id != object.get('id') || e.name != 'top'; }); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'left').value = - object.get('left') - artboard.get('left'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'top').value = - object.get('top') - artboard.get('top'); + setDefaultValue(object, 'left', object.get('left') - artboard.get('left')); + setDefaultValue(object, 'top', object.get('top') - artboard.get('top')); } else if (prop == 'scaleX') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'height').value = - object.get('height'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'width').value = - object.get('width'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'scaleY').value = - object.get('scaleY'); + setDefaultValue(object, 'height', object.get('height')); + setDefaultValue(object, 'width', object.get('width')); + setDefaultValue(object, 'scaleY', object.get('scaleY')); objects.find((x) => x.id == object.get('id')).animate = $.grep( objects.find((x) => x.id == object.get('id')).animate, function (e) { @@ -2753,10 +2743,7 @@ function freezeProp(prop, object) { return e.id != object.get('id') || e.name != 'height'; }); } else if (prop == 'strokeWidth') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'stroke').value = - object.get('stroke'); + setDefaultValue(object, 'stroke', object.get('stroke')); objects.find((x) => x.id == object.get('id')).animate = $.grep( objects.find((x) => x.id == object.get('id')).animate, function (e) { @@ -2767,22 +2754,10 @@ function freezeProp(prop, object) { return e.id != object.get('id') || e.name != 'stroke'; }); } else if (prop == 'shadow.color') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.opacity').value = - object.shadow.opacity; - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.offsetX').value = - object.shadow.offsetX; - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.offsetY').value = - object.shadow.offsetY; - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.blur').value = - object.shadow.blur; + setDefaultValue(object, 'shadow.opacity', object.shadow.opacity); + setDefaultValue(object, 'shadow.offsetX', object.shadow.offsetX); + setDefaultValue(object, 'shadow.offsetY', object.shadow.offsetY); + setDefaultValue(object, 'shadow.blur', object.shadow.blur); keyframes = $.grep(keyframes, function (e) { return e.id != object.get('id') || e.name != 'shadow.opacity'; }); @@ -2820,10 +2795,7 @@ function freezeProp(prop, object) { } ); } else if (prop == 'charSpacing') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'lineHeight').value = - object.get('lineHeight'); + setDefaultValue(object, 'lineHeight', object.get('lineHeight')); objects.find((x) => x.id == object.get('id')).animate = $.grep( objects.find((x) => x.id == object.get('id')).animate, function (e) { @@ -2841,9 +2813,7 @@ function freezeProp(prop, object) { // Exception if (prop != 'left' && prop != 'shadow.color') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == prop).value = object.get(prop); + setDefaultValue(object, prop, object.get(prop)); } $( @@ -2944,7 +2914,11 @@ function lockLayer(e) { $(this).removeClass('locked'); $(this).attr('src', 'assets/lock.svg'); object.selectable = true; - $(this).parent().parent().parent().attr('draggable', true); + $(this) + .parent() + .parent() + .find('.layer-handle') + .attr('draggable', true); } else { $(this).addClass('locked'); $(this).attr('src', 'assets/locked.svg'); @@ -2953,7 +2927,11 @@ function lockLayer(e) { canvas.discardActiveObject(); canvas.renderAll(); } - $(this).parent().parent().parent().attr('draggable', false); + $(this) + .parent() + .parent() + .find('.layer-handle') + .attr('draggable', false); } save(); } @@ -2972,8 +2950,15 @@ function centerObject(object) { // Render a layer function renderLayer(object, animate = false) { + const entry = objects.find((x) => x.id == object.get('id')); + const p_keyframe = p_keyframes.find( + (x) => x.id == object.get('id') + ); + if (!entry || !p_keyframe) { + return; + } $('#nolayers').addClass('yaylayers'); - const color = objects.find((x) => x.id == object.get('id')).color; + const color = entry.color; var src = ''; var classlock = ''; var srclock = 'lock'; @@ -3014,20 +2999,14 @@ function renderLayer(object, animate = false) { if (animate != false) { freeze = 'frozen'; } - const leftoffset = - p_keyframes.find((x) => x.id == object.get('id')).trimstart / - timelinetime; + const leftoffset = p_keyframe.trimstart / timelinetime; const width = - (p_keyframes.find((x) => x.id == object.get('id')).end - - p_keyframes.find((x) => x.id == object.get('id')).trimstart) / - timelinetime; + (p_keyframe.end - p_keyframe.trimstart) / timelinetime; $('#inner-timeline').prepend( "
x.id == object.get('id')).start) / - timelinetime + + (p_keyframe.end - p_keyframe.start) / timelinetime + "px'>
{ - return { - element: document.getElementById('nothing'), - posX: event.pageX - elementOffset.left, - posY: event.pageY - elementOffset.top, - }; - }, - }); + // Make the new layer draggable (html5sortable only wires up the + // children that exist when it is initialized) + if (typeof initLayerSortable == 'function') { + initLayerSortable(); + } if (object.selectable == false) { - $(".layer[data-object='" + object.get('id') + "']").attr( - 'draggable', - false - ); + $(".layer[data-object='" + object.get('id') + "']") + .find('.layer-handle') + .attr('draggable', false); } } // Render a property function renderProp(prop, object) { var classfreeze = ''; - srcfreeze = 'freeze'; - if ( - $.inArray( - prop, - objects.find((x) => x.id == object.get('id')).animate - ) != -1 - ) { + var srcfreeze = 'freeze'; + const entry = objects.find((x) => x.id == object.get('id')); + if (!entry) { + return; + } + if ($.inArray(prop, entry.animate) != -1) { classfreeze = 'frozen'; srcfreeze = 'frozen'; } @@ -3218,6 +3189,10 @@ function newLayer(object) { } else if (object.get('assetType') == 'audio') { color = '#11C0F7'; } + } else if (object.get('type') == 'lottie') { + color = '#F1890E'; + } else { + color = '#9211F7'; } if ( (object.get('assetType') && object.get('assetType') == 'video') || @@ -3235,25 +3210,20 @@ function newLayer(object) { start: 0, end: object.get('duration'), }); - if (object.get('duration') < duration) { - p_keyframes.push({ - start: currenttime, - end: object.get('duration') + currenttime, - trimstart: 0, - trimend: object.get('duration') + currenttime, - object: object, - id: object.get('id'), - }); - } else { - p_keyframes.push({ - start: currenttime, - end: duration - currenttime, - trimstart: 0, - trimend: duration - currenttime, - object: object, - id: object.get('id'), - }); - } + // Clamp to the project end. This used to be `duration - currenttime`, + // which shortened every layer added after t=0. + const mediaend = Math.min( + object.get('duration') + currenttime, + duration + ); + p_keyframes.push({ + start: currenttime, + end: mediaend, + trimstart: 0, + trimend: mediaend, + object: object, + id: object.get('id'), + }); } else { objects.push({ object: object, @@ -3264,25 +3234,16 @@ function newLayer(object) { locked: [], mask: 'none', }); - if (object.get('notnew')) { - p_keyframes.push({ - start: object.get('starttime'), - end: duration - object.get('starttime'), - trimstart: 0, - trimend: duration - currenttime, - object: object, - id: object.get('id'), - }); - } else { - p_keyframes.push({ - start: currenttime, - end: duration - currenttime, - trimstart: 0, - trimend: duration - currenttime, - object: object, - id: object.get('id'), - }); - } + p_keyframes.push({ + start: object.get('notnew') + ? object.get('starttime') + : currenttime, + end: duration, + trimstart: 0, + trimend: duration, + object: object, + id: object.get('id'), + }); } renderLayer(object); if ( @@ -3370,9 +3331,11 @@ function newLayer(object) { $(".layer[data-object='" + object.get('id') + "']").addClass( 'layer-selected' ); - document - .getElementsByClassName('layer-selected')[0] - .scrollIntoView(); + const selectedLayer = + document.getElementsByClassName('layer-selected')[0]; + if (selectedLayer) { + selectedLayer.scrollIntoView(); + } objects.find((x) => x.id == object.id).animate = []; animate(false, currenttime); save(); @@ -3502,6 +3465,10 @@ function loadVideo(src, x, y, center) { vidSrc.src = src; vidObj.crossOrigin = 'anonymous'; vidObj.appendChild(vidSrc); + vidObj.addEventListener('error', function () { + console.error('Could not load video', src); + $('#load-video').removeClass('loading-active'); + }); vidObj.addEventListener('loadeddata', function () { vidObj.width = this.videoWidth; vidObj.height = this.videoHeight; @@ -3547,17 +3514,20 @@ function checkCrop(obj) { // Perform a crop function crop(obj) { - var crop = canvas.getItemById('crop'); + var cropUI = canvas.getItemById('crop'); + if (!obj || !cropUI || !cropobj) { + return; + } cropobj.setCoords(); - crop.setCoords(); + cropUI.setCoords(); var cleft = - crop.get('left') - (crop.get('width') * crop.get('scaleX')) / 2; + cropUI.get('left') - (cropUI.get('width') * cropUI.get('scaleX')) / 2; var ctop = - crop.get('top') - (crop.get('height') * crop.get('scaleY')) / 2; + cropUI.get('top') - (cropUI.get('height') * cropUI.get('scaleY')) / 2; var height = - (crop.get('height') / cropobj.get('scaleY')) * crop.get('scaleY'); + (cropUI.get('height') / cropobj.get('scaleY')) * cropUI.get('scaleY'); var width = - (crop.get('width') / cropobj.get('scaleX')) * crop.get('scaleX'); + (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 = @@ -3603,8 +3573,8 @@ function crop(obj) { canvas.renderAll(); } if (obj.get('id') != 'cropped') { - canvas.remove(crop); - canvas.remove(canvas.getItemById('overlay')); + canvas.remove(cropUI); + canvas.remove(canvas.getItemById('crop-overlay')); canvas.remove(canvas.getItemById('cropped')); cropping = false; resetControls(); @@ -3613,7 +3583,7 @@ function crop(obj) { newKeyframe('scaleX', obj, currenttime, obj.get('scaleX'), true); newKeyframe('scaleY', obj, currenttime, obj.get('scaleY'), true); newKeyframe('width', obj, currenttime, obj.get('width'), true); - newKeyframe('height', obj, currenttime, obj.get('width'), true); + newKeyframe('height', obj, currenttime, obj.get('height'), true); newKeyframe('left', obj, currenttime, obj.get('left'), true); newKeyframe('top', obj, currenttime, obj.get('top'), true); $('#properties-overlay').removeClass('properties-disabled'); @@ -3642,7 +3612,8 @@ function overlay() { height: artboard.height, fill: 'rgba(0,0,0,0.5)', selectable: false, - id: 'overlay', + // Not "overlay": that is the artboard's id + id: 'crop-overlay', }) ); } @@ -3863,6 +3834,10 @@ function loadImage(src, x, y, width, center) { image.onload = function (img) { newImage(image, x, y, width, center); }; + image.onerror = function () { + console.error('Could not load image', src); + $('#load-image').removeClass('loading-active'); + }; image.src = src; } @@ -3890,19 +3865,15 @@ function createVideoThumbnail(file, max, seekTo = 0.0, isURL) { videoPlayer.addEventListener('seeked', () => { var oc = document.createElement('canvas'); var octx = oc.getContext('2d'); - oc.width = videoPlayer.videoWidth; - oc.height = videoPlayer.videoheight; - octx.drawImage(videoPlayer, 0, 0); if (videoPlayer.videoWidth > videoPlayer.videoHeight) { + oc.width = max; oc.height = (videoPlayer.videoHeight / videoPlayer.videoWidth) * max; - oc.width = max; } else { + oc.height = max; oc.width = (videoPlayer.videoWidth / videoPlayer.videoHeight) * max; - oc.height = max; } - octx.drawImage(oc, 0, 0, oc.width, oc.height); octx.drawImage(videoPlayer, 0, 0, oc.width, oc.height); resolve(oc.toDataURL()); }); @@ -3919,17 +3890,13 @@ function createThumbnail(file, max) { if (img.width > max) { var oc = document.createElement('canvas'); var octx = oc.getContext('2d'); - oc.width = img.width; - oc.height = img.height; - octx.drawImage(img, 0, 0); if (img.width > img.height) { - oc.height = (img.height / img.width) * max; oc.width = max; + oc.height = (img.height / img.width) * max; } else { - oc.width = (img.width / img.height) * max; oc.height = max; + oc.width = (img.width / img.height) * max; } - octx.drawImage(oc, 0, 0, oc.width, oc.height); octx.drawImage(img, 0, 0, oc.width, oc.height); resolve(oc.toDataURL()); } else { @@ -4291,34 +4258,35 @@ $(document).on('click', '.align-text', alignText); // Change font function changeFont() { var font = $('#font-picker').val(); - if (canvas.getActiveObject().get('assetType')) { + const active = canvas.getActiveObject(); + if (!active || !font) { + return; + } + if (active.get('assetType')) { WebFont.load({ google: { families: [font], }, active: () => { - var object = canvas.getActiveObject(); - animatedtext - .find((x) => x.id == object.id) - .reset( - animatedtext.find((x) => x.id == object.id).text, - $.extend( - animatedtext.find((x) => x.id == object.id).props, - { fontFamily: font } - ), - canvas - ); + const text = animatedtext.find((x) => x.id == active.id); + if (!text) { + return; + } + text.reset( + text.text, + $.extend(text.props, { fontFamily: font }), + canvas + ); save(); }, }); - save(); } else { WebFont.load({ google: { families: [font], }, active: () => { - canvas.getActiveObject().set('fontFamily', font); + active.set('fontFamily', font); canvas.renderAll(); save(); }, @@ -4396,7 +4364,7 @@ function newTextbox( strokeDashArray: false, width: calculateTextWidth( text, - fontweight + ' ' + fontsize + 'px Inter' + fontweight + ' ' + fontsize + 'px ' + (font || 'Inter') ), id: 'Text' + layer_count, shadow: { @@ -4440,11 +4408,22 @@ function deleteObject(object, def = true) { }); } if (object.type == 'image') { - var temp = files.find((x) => x.name == object.get('id')); files = $.grep(files, function (a) { - return a != temp.name; + return a.name != object.get('id'); }); } + // Release any media element the object was holding on to + if (typeof object.getElement === 'function') { + const el = object.getElement(); + if (el && el.tagName == 'VIDEO') { + el.pause(); + el.removeAttribute('src'); + el.load(); + } + } + if (object.get('src') && typeof object.get('src').pause === 'function') { + object.get('src').pause(); + } $(".layer[data-object='" + object.get('id') + "']").remove(); $('#' + object.get('id')).remove(); keyframes = $.grep(keyframes, function (e) { @@ -4519,29 +4498,25 @@ function setDuration(length) { ('0' + Math.floor((seconds % 1) * 100)).slice(-2) ); $('.object-props').each(function () { + const p_keyframe = p_keyframes.find( + (x) => x.id == $(this).attr('id') + ); + if (!p_keyframe) { + return; + } $(this).css( 'width', - duration / timelinetime - - p_keyframes.find((x) => x.id == $(this).attr('id')).start / - timelinetime + - 'px' + duration / timelinetime - p_keyframe.start / timelinetime + 'px' ); - p_keyframes.find((x) => x.id == $(this).attr('id')).end = - duration; - if ( - p_keyframes.find((x) => x.id == $(this).attr('id')).trimend > - p_keyframes.find((x) => x.id == $(this).attr('id')).end - ) { - p_keyframes.find((x) => x.id == $(this).attr('id')).trimend = - duration; + p_keyframe.end = duration; + if (p_keyframe.trimend > p_keyframe.end) { + p_keyframe.trimend = duration; $(this) .find('.trim-row') .css( 'width', duration / timelinetime - - p_keyframes.find((x) => x.id == $(this).attr('id')) - .trimstart / - timelinetime + + p_keyframe.trimstart / timelinetime + 'px' ); } @@ -4578,7 +4553,7 @@ function renderTimeMarkers() { 's
' ); if (timenumber % modulo != 0) { - $('.time-number:last-child()').css('opacity', '0'); + $('#time-numbers .time-number:last-child').css('opacity', '0'); } timenumber++; } @@ -4587,21 +4562,22 @@ function renderTimeMarkers() { // Change timeline zoom level function setTimelineZoom(time) { $('.object-props').each(function () { + const p_keyframe = p_keyframes.find( + (x) => x.id == $(this).attr('id') + ); + if (!p_keyframe) { + return; + } $(this).offset({ left: - p_keyframes.find((x) => x.id == $(this).attr('id')).start / - time + + p_keyframe.start / time + $('#inner-timeline').offset().left + offset_left, }); $(this).css({ width: ($(this).width() * timelinetime) / time }); $(this) .find('.trim-row') - .css({ - left: - p_keyframes.find((x) => x.id == $(this).attr('id')) - .trimstart / time, - }); + .css({ left: p_keyframe.trimstart / time }); $(this) .find('.trim-row') .css({ @@ -4635,95 +4611,18 @@ $(document).on('input', '#timeline-zoom', function () { }); function removeKeyframe() { + if (!selectedkeyframe) { + return; + } + const time = selectedkeyframe.attr('data-time'); + const id = selectedkeyframe.attr('data-object'); + const prop = selectedkeyframe.attr('data-property'); + const names = [prop].concat(KEYFRAME_COUNTERPARTS[prop] || []); keyframes = $.grep(keyframes, function (e) { return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != selectedkeyframe.attr('data-property') + e.t != time || e.id != id || names.indexOf(e.name) == -1 ); }); - if (selectedkeyframe.attr('data-property') == 'left') { - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'top' - ); - }); - } else if (selectedkeyframe.attr('data-property') == 'scaleX') { - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'scaleY' - ); - }); - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'width' - ); - }); - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'height' - ); - }); - } else if ( - selectedkeyframe.attr('data-property') == 'strokeWidth' - ) { - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'stroke' - ); - }); - } else if ( - selectedkeyframe.attr('data-property') == 'shadow.color' - ) { - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'shadow.blur' - ); - }); - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'shadow.offsetX' - ); - }); - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'shadow.offsetY' - ); - }); - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'shadow.opacity' - ); - }); - } else if ( - selectedkeyframe.attr('data-property') == 'charSpacing' - ) { - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'lineHeight' - ); - }); - } selectedkeyframe.remove(); $('#keyframe-properties').removeClass('show-properties'); } @@ -4744,437 +4643,126 @@ function deleteKeyframe() { } $(document).on('click', '#delete-keyframe', deleteKeyframe); +// Properties that are always keyframed together with a leading property +const KEYFRAME_COUNTERPARTS = { + left: ['top'], + scaleX: ['scaleY', 'width', 'height'], + strokeWidth: ['stroke'], + 'shadow.color': [ + 'shadow.opacity', + 'shadow.offsetX', + 'shadow.offsetY', + 'shadow.blur', + ], + charSpacing: ['lineHeight'], +}; + // Copy keyframes function copyKeyframes() { + if (!Array.isArray(clipboard) || clipboard.length == 0) { + return; + } clipboard.sort(function (a, b) { return a.t - b.t; }); var inittime = clipboard[0].t; clipboard.forEach(function (keyframe) { + const object = canvas.getItemById(keyframe.id); + if (!object) { + return; + } var newtime = keyframe.t - inittime + currenttime; newKeyframe( keyframe.name, - canvas.getItemById(keyframe.id), + object, newtime, keyframe.value, true ); - var keyprop = keyframe.name; - if (keyprop == 'left') { + // Counterpart properties are stored as separate keyframes at the same + // time; copy whichever of them actually exist. + const counterparts = KEYFRAME_COUNTERPARTS[keyframe.name] || []; + counterparts.forEach(function (name) { const keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && e.id == keyframe.id && e.name == 'top' - ); - }); - newKeyframe( - 'top', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - } else if (keyprop == 'scaleX') { - var keyarr2 = $.grep(keyframes, function (e) { return ( e.t == keyframe.t && e.id == keyframe.id && - e.name == 'scaleY' - ); - }); - newKeyframe( - 'scaleY', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'width' + e.name == name ); }); if (keyarr2.length > 0) { - newKeyframe( - 'width', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); + newKeyframe(name, object, newtime, keyarr2[0].value, true); } - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'height' - ); - }); - if (keyarr2.length > 0) { - newKeyframe( - 'height', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - } - } else if (keyprop == 'strokeWidth') { - const keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'stroke' - ); - }); - newKeyframe( - 'stroke', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - } else if (keyprop == 'charSpacing') { - const keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'lineHeight' - ); - }); - newKeyframe( - 'lineHeight', - canvas.getItemByid(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - } else if (keyprop == 'shadow.color') { - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'shadow.opacity' - ); - }); - newKeyframe( - 'shadow.opacity', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'shadow.offsetX' - ); - }); - newKeyframe( - 'shadow.offsetX', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'shadow.offsetY' - ); - }); - - newKeyframe( - 'shadow.offsetY', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'shadow.blur' - ); - }); - newKeyframe( - 'shadow.blur', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - } - save(); + }); }); + save(); } -// Update keyframe (after dragging) -function updateKeyframe(drag, newval, offset) { - var time = parseFloat( - (drag.position().left * timelinetime).toFixed(1) - ); +// Update keyframe (after dragging, or when re-stamping it at the playhead) +function updateKeyframe(drag, newval) { const keyprop = drag.attr('data-property'); const keytime = drag.attr('data-time'); + const objectid = drag.attr('data-object'); const keyarr = $.grep(keyframes, function (e) { return ( e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && + e.id == objectid && e.name == keyprop ); }); + if (keyarr.length == 0) { + return; + } const keyobj = canvas.getItemById(keyarr[0].id); - time = - parseFloat( - p_keyframes.find((x) => x.id == keyobj.get('id')).start - ) + time; - if (newval) { - time = currenttime; + const p_keyframe = p_keyframes.find((x) => x.id == objectid); + if (!keyobj || !p_keyframe) { + return; } - var keyval = keyarr[0].value; - if (newval) { - if (keyprop == 'shadow.color') { - keyval = keyobj.shadow.color; - } else if (keyprop == 'volume') { - keyval = parseFloat($('#object-volume input').val() / 200); - } else { - keyval = keyobj.get(keyprop); - } - } else if (keyprop == 'left') { - keyval = keyval + artboard.get('left'); - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != keyprop - ); - }); - newKeyframe(keyprop, keyobj, time, keyval, false); - if (keyprop == 'left') { - const keyarr2 = $.grep(keyframes, function (e) { + // data-time is always absolute; drag.position() is relative to the layer + // row, which is itself offset by the layer start. + const time = newval + ? currenttime + : parseFloat(p_keyframe.start) + + parseFloat((drag.position().left * timelinetime).toFixed(1)); + + const names = [keyprop].concat( + KEYFRAME_COUNTERPARTS[keyprop] || [] + ); + names.forEach(function (name) { + const arr = $.grep(keyframes, function (e) { return ( e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'top' + e.id == objectid && + e.name == name ); }); - var keyval2 = keyarr2[0].value + artboard.get('top'); + if (arr.length == 0) { + return; + } + let value = arr[0].value; if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).get('top'); - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'top' - ); - }); - newKeyframe('top', keyobj, time, keyval2, false); - } else if (keyprop == 'scaleX') { - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'scaleY' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).get('scaleY'); - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'scaleY' - ); - }); - newKeyframe('scaleY', keyobj, time, keyval2, false); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'width' - ); - }); - if (keyarr2.length > 0) { - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).get('width'); + if (name == 'volume') { + value = parseFloat($('#object-volume input').val()) / 200; + } else { + value = getPropValue(keyobj, name); } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'width' - ); - }); - newKeyframe('width', keyobj, time, keyval2, false); - } - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'height' - ); - }); - if (keyarr2.length > 0) { - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).get('height'); - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'height' - ); - }); - newKeyframe('height', keyobj, time, keyval2, false); - } - } else if (keyprop == 'strokeWidth') { - const keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'stroke' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).get('stroke'); + } else if (name == 'left') { + // newKeyframe stores left/top relative to the artboard + value = value + artboard.get('left'); + } else if (name == 'top') { + value = value + artboard.get('top'); } keyframes = $.grep(keyframes, function (e) { return ( e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'stroke' + e.id != objectid || + e.name != name ); }); - newKeyframe('stroke', keyobj, time, keyval2, false); - } else if (keyprop == 'charSpacing') { - const keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'lineHeight' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).get('lineHeight'); - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'lineHeight' - ); - }); - newKeyframe('lineHeight', keyobj, time, keyval2, false); - } else if (keyprop == 'shadow.color') { - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'shadow.opacity' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).shadow.opacity; - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'shadow.opacity' - ); - }); - newKeyframe('shadow.opacity', keyobj, time, keyval2, false); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'shadow.offsetX' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).shadow.offsetX; - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'shadow.offsetX' - ); - }); - newKeyframe('shadow.offsetX', keyobj, time, keyval2, false); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'shadow.offsetY' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).shadow.offsetY; - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'shadow.offsetY' - ); - }); - newKeyframe('shadow.offsetY', keyobj, time, keyval2, false); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'shadow.blur' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).shadow.blur; - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'shadow.blur' - ); - }); - newKeyframe('shadow.blur', keyobj, time, keyval2, false); - } - if (offset) { - drag.attr('data-time', time); - } else { - drag.attr( - 'data-time', - time + p_keyframes.find((x) => x.id == keyarr[0].id).start - ); - } - keyframes.sort(function (a, b) { - if (a.id.indexOf('Group') >= 0 && b.id.indexOf('Group') == -1) { - return 1; - } else if ( - b.id.indexOf('Group') >= 0 && - a.id.indexOf('Group') == -1 - ) { - return -1; - } else { - return 0; - } + newKeyframe(name, keyobj, time, value, false); }); + drag.attr('data-time', time); + sortKeyframes(); } function keyframeSnap(drag) { @@ -5193,11 +4781,12 @@ function keyframeSnap(drag) { }); $('#line-snap').addClass('line-active'); } else { + let snapped = false; drag .parent() .parent() .find('.keyframe') - .each(function (index) { + .each(function () { if (!drag.is($(this))) { if ( drag.offset().left > $(this).offset().left - 5 && @@ -5212,17 +4801,67 @@ function keyframeSnap(drag) { height: drag.parent().parent().height(), }); $('#line-snap').addClass('line-active'); + snapped = true; return false; } } - if (index == $('.keyframe').length - 1) { - $('#line-snap').removeClass('line-active'); - } }); + if (!snapped) { + $('#line-snap').removeClass('line-active'); + } } } } +// Shared plumbing for the timeline drags (seekbar, keyframes, layer bars, +// timeline resize handle). Captures the pointer so the matching pointerup +// always comes back to us, and tears the drag down on pointercancel, on a +// stray native dragstart, or when the window loses focus. Without this a +// swallowed mouseup leaves the dragged element glued to the cursor. +function bindPointerDrag(e, el, onMove, onEnd) { + var pointerId = + e.originalEvent && e.originalEvent.pointerId != undefined + ? e.originalEvent.pointerId + : null; + if (pointerId != null && el && el.setPointerCapture) { + try { + el.setPointerCapture(pointerId); + } catch (err) {} + } + function end(ev) { + // Ignore another pointer going up while this one is still dragging + if ( + ev && + ev.originalEvent && + pointerId != null && + ev.originalEvent.pointerId != undefined && + ev.originalEvent.pointerId != pointerId + ) { + return; + } + $(document) + .off('pointermove', onMove) + .off('pointerup', end) + .off('pointercancel', end) + .off('dragstart', end); + window.removeEventListener('blur', end); + if (pointerId != null && el && el.releasePointerCapture) { + try { + el.releasePointerCapture(pointerId); + } catch (err) {} + } + onEnd(ev); + } + $(document) + .on('pointermove', onMove) + .on('pointerup', end) + .on('pointercancel', end) + .on('dragstart', end); + // Native listener on purpose: jQuery routes blur through its focus special + // event, which defers the handler and can drop it entirely + window.addEventListener('blur', end); +} + // Dragging a keyframe function dragKeyframe(e) { if (e.which == 3) { @@ -5243,8 +4882,9 @@ function dragKeyframe(e) { }); $(this).addClass('keyframe-selected'); } else { + const el = this; shiftkeys = $.grep(shiftkeys, function (e) { - return e.keyframe != this; + return e.keyframe !== el; }); $(this).removeClass('keyframe-selected'); } @@ -5281,9 +4921,6 @@ function dragKeyframe(e) { } } function releasedKeyframe(e) { - $('body') - .off('mousemove', draggingKeyframe) - .off('mouseup', releasedKeyframe); $('#line-snap').removeClass('line-active'); if (move) { if (shiftkeys.length == 0) { @@ -5321,18 +4958,16 @@ function dragKeyframe(e) { } }); } - } else if (!e.shiftDown) { + } else if (e && e.type == 'pointerup' && !e.shiftKey) { keyframeProperties(inst); } move = false; $('.line-active').removeClass('line-active'); save(); } - $('body') - .on('mouseup', releasedKeyframe) - .on('mousemove', draggingKeyframe); + bindPointerDrag(e, this, draggingKeyframe, releasedKeyframe); } -$(document).on('mousedown', '.keyframe', dragKeyframe); +$(document).on('pointerdown', '.keyframe', dragKeyframe); // Render current time in the playback area function renderTime() { @@ -5382,6 +5017,10 @@ function dragSeekBar(e) { if (e.which == 3) { return false; } + // Stop the browser from turning the press into a native drag-and-drop. + // When it does, the mouseup is swallowed, released() never runs and the + // seekbar stays glued to the pointer. + e.preventDefault(); var drag = $(this); var pageX = e.pageX; var offset = $(this).offset(); @@ -5425,7 +5064,6 @@ function dragSeekBar(e) { renderTime(); } function released(e) { - $('body').off('mousemove', dragging).off('mouseup', released); updateTime(drag, false); seeking = false; if (tempselection && tempselection.type != 'activeSelection') { @@ -5433,9 +5071,17 @@ function dragSeekBar(e) { } updatePanelValues(); } - $('body').on('mouseup', released).on('mousemove', dragging); + bindPointerDrag(e, this, dragging, released); } -$(document).on('mousedown', '#seekbar', dragSeekBar); +$(document).on('pointerdown', '#seekbar', dragSeekBar); +// The timeline drag targets must never become native drag sources +$(document).on( + 'dragstart', + '#seekbar, .keyframe, .main-row, .row-el, .trim-row, #timeline-handle', + function (e) { + e.preventDefault(); + } +); // Dragging layer horizontally function dragObjectProps(e) { @@ -5562,7 +5208,7 @@ function dragObjectProps(e) { setTimelineZoom(timelinetime); } drag.find('.keyframe').each(function () { - updateKeyframe($(this), false, true); + updateKeyframe($(this), false); }); animate(false, currenttime); } else if (trim == 'left') { @@ -5605,7 +5251,6 @@ function dragObjectProps(e) { } } function released(e) { - $('body').off('mousemove', dragging).off('mouseup', released); if (opened) { $(".layer[data-object='" + drag.attr('id') + "']") .find('.properties') @@ -5621,9 +5266,9 @@ function dragObjectProps(e) { animate(false, currenttime); save(); } - $('body').on('mouseup', released).on('mousemove', dragging); + bindPointerDrag(e, this, dragging, released); } -$(document).on('mousedown', '.main-row', dragObjectProps); +$(document).on('pointerdown', '.main-row', dragObjectProps); function resetHeight() { var top = $(window).height() - oldtimelinepos - 92; @@ -5654,31 +5299,29 @@ function resetHeight() { // Dragging timeline vertically function dragTimeline(e) { - const disableselect = (e) => { - return false - } - document.onselectstart = disableselect - document.onmousedown = disableselect - - oldtimelinepos = e.pageY; if (e.which == 3) { return false; } + // Suppress text selection for the duration of the drag only - leaving these + // handlers installed kills mousedown for the whole document. + const disableselect = function () { + return false; + }; + const previousSelectStart = document.onselectstart; + document.onselectstart = disableselect; + + oldtimelinepos = e.pageY; function draggingKeyframe(e) { oldtimelinepos = e.pageY; resetHeight(e); } function releasedKeyframe(e) { - $('body') - .off('mousemove', draggingKeyframe) - .off('mouseup', releasedKeyframe); + document.onselectstart = previousSelectStart || null; } - $('body') - .on('mouseup', releasedKeyframe) - .on('mousemove', draggingKeyframe); + bindPointerDrag(e, this, draggingKeyframe, releasedKeyframe); } -$(document).on('mousedown', '#timeline-handle', dragTimeline); +$(document).on('pointerdown', '#timeline-handle', dragTimeline); oldtimelinepos = $(window).height() - 92 - $('#timearea').height(); @@ -5749,6 +5392,9 @@ function keyframeProperties(inst) { e.name == selectedkeyframe.attr('data-property') ); }); + if (keyarr.length == 0) { + return; + } $('#easing select').val(keyarr[0].easing); $('#easing select').niceSelect('update'); popup.css({ @@ -5762,108 +5408,28 @@ function keyframeProperties(inst) { // Apply easing to keyframe function applyEasing() { - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == selectedkeyframe.attr('data-property') - ); - }); - keyarr[0].easing = $(this).attr('data-value'); - if (selectedkeyframe.attr('data-property') == 'left') { - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'top' - ); - }); - keyarr[0].easing = $('#easing select').val(); - } else if (selectedkeyframe.attr('data-property') == 'scaleX') { - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'scaleY' - ); - }); - keyarr[0].easing = $('#easing select').val(); - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'width' - ); - }); - keyarr[0].easing = $('#easing select').val(); - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'height' - ); - }); - keyarr[0].easing = $('#easing select').val(); - } else if ( - selectedkeyframe.attr('data-property') == 'strokeWidth' - ) { - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'stroke' - ); - }); - keyarr[0].easing = $('#easing select').val(); - } else if ( - selectedkeyframe.attr('data-property') == 'shadow.color' - ) { - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'shadow.opacity' - ); - }); - keyarr[0].easing = $('#easing select').val(); - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'shadow.offsetX' - ); - }); - keyarr[0].easing = $('#easing select').val(); - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'shadow.offsetY' - ); - }); - keyarr[0].easing = $('#easing select').val(); - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'shadow.blur' - ); - }); - keyarr[0].easing = $('#easing select').val(); - } else if ( - selectedkeyframe.attr('data-property') == 'charSpacing' - ) { - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'lineHeight' - ); - }); - keyarr[0].easing = $('#easing select').val(); + if (!selectedkeyframe) { + return; } + // Read the clicked item, not the underlying