From e5c2ec1e66263f80283a9952b5e1bce365ea1eb6 Mon Sep 17 00:00:00 2001 From: iVy Deliz Date: Mon, 10 Feb 2025 14:28:11 -0700 Subject: [PATCH] Copy & Paste of Activity Directives between Plans (#1625) --- .../activity/ActivityDirectivesTable.svelte | 37 ++++-- .../timeline/TimelineContextMenu.svelte | 55 ++++---- src/utilities/activities.ts | 124 +++++++++--------- src/utilities/clipboard.ts | 28 ++++ src/utilities/sessionStorageClipboard.ts | 7 - 5 files changed, 144 insertions(+), 107 deletions(-) create mode 100644 src/utilities/clipboard.ts delete mode 100644 src/utilities/sessionStorageClipboard.ts diff --git a/src/components/activity/ActivityDirectivesTable.svelte b/src/components/activity/ActivityDirectivesTable.svelte index 4f5e4ea906..1defdf987e 100644 --- a/src/components/activity/ActivityDirectivesTable.svelte +++ b/src/components/activity/ActivityDirectivesTable.svelte @@ -10,7 +10,7 @@ import type { ActivityErrorCounts, ActivityErrorRollup } from '../../types/errors'; import type { Plan } from '../../types/plan'; import { - canPasteActivityDirectivesFromClipboard, + getActivityDirectivesClipboardCount, copyActivityDirectivesToClipboard, getActivityDirectivesToPaste, getPasteActivityDirectivesText, @@ -166,17 +166,28 @@ } } - function canPasteActivityDirectives(): boolean { - return plan !== null && hasCreatePermission && canPasteActivityDirectivesFromClipboard(plan); + function canPasteActivityDirectives(): Promise { + return new Promise(resolve => { + getActivityDirectivesClipboardCount().then(result => { + if (plan !== null && hasCreatePermission && result > 0) { + resolve(result); //returns how many activity directives there are + } else { + resolve(-1); + } + }); + }); } function pasteActivityDirectives() { - if (plan !== null && canPasteActivityDirectives()) { - const directives = getActivityDirectivesToPaste(plan); - if (directives !== undefined) { - dispatch(`createActivityDirectives`, directives); + canPasteActivityDirectives().then(pasteCount => { + if (pasteCount > 0 && plan !== null) { + getActivityDirectivesToPaste(plan).then(directives => { + if (directives !== undefined) { + dispatch(`createActivityDirectives`, directives); + } + }); } - } + }); } @@ -214,9 +225,11 @@ Scroll to Activity {/if} - {#if canPasteActivityDirectives()} - {getPasteActivityDirectivesText()} - - {/if} + {#await canPasteActivityDirectives() then howMany} + {#if howMany > 0} + {getPasteActivityDirectivesText(howMany)} + + {/if} + {/await} diff --git a/src/components/timeline/TimelineContextMenu.svelte b/src/components/timeline/TimelineContextMenu.svelte index b4665296e6..b9c122832b 100644 --- a/src/components/timeline/TimelineContextMenu.svelte +++ b/src/components/timeline/TimelineContextMenu.svelte @@ -22,7 +22,7 @@ VerticalGuide, } from '../../types/timeline'; import { - canPasteActivityDirectivesFromClipboard, + getActivityDirectivesClipboardCount, copyActivityDirectivesToClipboard, getAllSpansForActivityDirective, getSpanRootParent, @@ -256,20 +256,28 @@ plan !== null && copyActivityDirectivesToClipboard(plan, [activity]); } - function canPasteActivityDirectives(): boolean { - return ( - plan !== null && - featurePermissions.activityDirective.canCreate(user, plan) && - canPasteActivityDirectivesFromClipboard(plan) - ); + function canPasteActivityDirectives(): Promise { + return new Promise(resolve => { + if (plan === null || !featurePermissions.activityDirective.canCreate(user, plan)) { + resolve(-1); + } + getActivityDirectivesClipboardCount().then(result => { + if (result > 0) { + resolve(result); //returns how many activity directives there are + } else { + resolve(-1); + } + }); + }); } function pasteActivityDirectivesAtTime(time: Date | false | null) { if (plan !== null && featurePermissions.activityDirective.canCreate(user, plan) && time instanceof Date) { - const directives = getActivityDirectivesToPaste(plan, time.getTime()); - if (directives !== undefined) { - effects.cloneActivityDirectives(directives, plan, user); - } + getActivityDirectivesToPaste(plan, time.getTime()).then(directives => { + if (plan != null && directives !== undefined) { + effects.cloneActivityDirectives(directives, plan, user); + } + }); } } @@ -432,18 +440,19 @@ > Set Simulation End - {#if canPasteActivityDirectives()} - - { - if (xScaleView && offsetX !== undefined) { - pasteActivityDirectivesAtTime(xScaleView.invert(offsetX)); - } - }} - > - {getPasteActivityDirectivesText()} at Time - - {/if} + {#await canPasteActivityDirectives() then howMany} + {#if howMany > 0} + + { + if (xScaleView && offsetX !== undefined) { + pasteActivityDirectivesAtTime(xScaleView.invert(offsetX)); + } + }} + >{getPasteActivityDirectivesText(howMany)} at Time + + {/if} + {/await} {/if} {#if span} diff --git a/src/utilities/activities.ts b/src/utilities/activities.ts index 70753f6f5d..e5f265b3f7 100644 --- a/src/utilities/activities.ts +++ b/src/utilities/activities.ts @@ -11,8 +11,8 @@ import { getIntervalInMs, getUnixEpochTime, } from './time'; -import { getSessionStorageClipboard, setSessionStorageClipboard } from './sessionStorageClipboard'; -import { showSuccessToast } from './toast'; +import { getClipboardContent, setClipboardContent } from './clipboard'; +import { showFailureToast, showSuccessToast } from './toast'; /** * Updates activity metadata with a new key/value and removes any empty values. @@ -191,87 +191,81 @@ export function copyActivityDirectivesToClipboard(sourcePlan: Plan, activities: const clipboard = { activities: clippedActivities, - sourcePlanId: sourcePlan.id, + sourcePlan: sourcePlan.id, + type: `aerie_activity_directives`, }; - setSessionStorageClipboard(JSON.stringify(clipboard)); - showSuccessToast(`Copied ${activities.length} Activity Directive${activities.length === 1 ? '' : 's'}`); + const noun = `Activity Directive${activities.length === 1 ? '' : 's'}`; + setClipboardContent( + clipboard, + () => showSuccessToast(`Copied ${activities.length} ${noun}`), + () => showFailureToast(`Failed to copy ${activities.length} ${noun}`), + ); } -export function getPasteActivityDirectivesText(): string | undefined { - try { - const serializedClipboard = getSessionStorageClipboard(); - if (serializedClipboard !== null) { - const clipboard = JSON.parse(serializedClipboard); - if (Array.isArray(clipboard.activities)) { - const n = clipboard.activities.length; - return `Paste ${n} Activity Directive${n === 1 ? '' : 's'}`; - } - } - } catch (e) { - console.error('e'); - } +export function getPasteActivityDirectivesText(count: number): string { + return `Paste ${count} Activity Directive${count === 1 ? '' : 's'}`; } -export function canPasteActivityDirectivesFromClipboard(destinationPlan: Plan | null): boolean { - if (destinationPlan === null) { - return false; - } - - try { - const serializedClipboard = getSessionStorageClipboard(); - if (serializedClipboard === null) { - return false; - } - - const clipboard = JSON.parse(serializedClipboard); - //current scope, allows copy/paste in the same plan - if (clipboard.sourcePlanId !== destinationPlan.id) { - return false; - } - } catch (e) { - console.error(e); - return false; - } - - return true; +export async function getActivityDirectivesClipboardCount(): Promise { + return new Promise(resolve => { + getClipboardContent().then((serializedClipboard: string) => { + try { + const clipboard = JSON.parse(serializedClipboard); + if (clipboard.type === 'aerie_activity_directives' && clipboard.activities !== undefined) { + resolve(clipboard.activities.length); + } + } catch (e) { + //eh silent + } + resolve(-1); + }); + }); } -export function getActivityDirectivesToPaste( +export async function getActivityDirectivesToPaste( destinationPlan: Plan, pasteStartingAtTime?: number, -): ActivityDirective[] | undefined { +): Promise { try { - const serializedClipboard = getSessionStorageClipboard(); + const serializedClipboard = await getClipboardContent(); if (serializedClipboard !== null) { const clipboard = JSON.parse(serializedClipboard); const activities: ActivityDirective[] = clipboard.activities; - //transpose in time if we're given a time, otherwise it paste at the current time - if (typeof pasteStartingAtTime === 'number') { - const starts: number[] = []; - activities.forEach(a => { - //unachored activities are the ones we're trying to place relative to each other in time, anchored will be calculated from offset - if (a.anchor_id === null && a.start_time_ms !== null) { - starts.push(a.start_time_ms); - } - }); + const starts: number[] = []; + activities.forEach(a => { + //unachored activities are the ones we're trying to place relative to each other in time, anchored will be calculated from offset + if (a.anchor_id === null && a.start_time_ms !== null) { + starts.push(a.start_time_ms); + } + }); - const planStart = getUnixEpochTime(destinationPlan.start_time_doy); - const earliestStart = Math.max(planStart, Math.min(...starts)); //bounded by plan start - const diff = pasteStartingAtTime - earliestStart; + //bounded by plan start and plan end + const planStart = getUnixEpochTime(destinationPlan.start_time_doy); + const planEnd = getUnixEpochTime(destinationPlan.end_time_doy); + const earliestStart = Math.min(...starts); + if (earliestStart < planStart || earliestStart > planEnd) { + pasteStartingAtTime = planStart; //if out of bounds, paste starting at the start of the plan. + } - activities.forEach(activity => { - if (activity.start_time_ms !== null) { - //anchored activities don't need offset to be updated - if (activity.anchor_id === null) { - activity.start_time_ms += diff; - const startTimeDoy = getDoyTime(new Date(activity.start_time_ms)); - activity.start_offset = getIntervalFromDoyRange(destinationPlan.start_time_doy, startTimeDoy); - } - } - }); + //transpose in time if we're given a time or if it was out of bounds + let diff = 0; + if (typeof pasteStartingAtTime === 'number') { + diff = pasteStartingAtTime - earliestStart; } + + activities.forEach(activity => { + if (activity.start_time_ms !== null) { + //anchored activities don't need offset to be updated + if (activity.anchor_id === null) { + activity.start_time_ms += diff; + const startTimeDoy = getDoyTime(new Date(activity.start_time_ms)); + activity.start_offset = getIntervalFromDoyRange(destinationPlan.start_time_doy, startTimeDoy); + } + } + }); + return activities; } } catch (e) { diff --git a/src/utilities/clipboard.ts b/src/utilities/clipboard.ts new file mode 100644 index 0000000000..f463524163 --- /dev/null +++ b/src/utilities/clipboard.ts @@ -0,0 +1,28 @@ +export function setClipboardContent(content: object, success?: () => void, fail?: () => void) { + navigator.permissions.query({ name: 'clipboard-write' as PermissionName }).then(result => { + if (result.state === 'granted' || result.state === 'prompt') { + navigator.clipboard.writeText(JSON.stringify(content)).then( + () => { + if (success !== undefined) { + success(); //optional success callback + } + }, + () => { + if (fail !== undefined) { + fail(); //optional fail callback + } + }, + ); + } + }); +} + +export async function getClipboardContent(): Promise { + return new Promise(resolve => { + try { + window.navigator.clipboard.readText().then(clipText => resolve(clipText)); + } catch (e) { + resolve(`{}`); //empty object string? + } + }); +} diff --git a/src/utilities/sessionStorageClipboard.ts b/src/utilities/sessionStorageClipboard.ts deleted file mode 100644 index 4c112419e2..0000000000 --- a/src/utilities/sessionStorageClipboard.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function setSessionStorageClipboard(content: string) { - sessionStorage.setItem(`aerie_clipboard`, content); -} - -export function getSessionStorageClipboard(): string | null { - return sessionStorage.getItem(`aerie_clipboard`); -}