From 3796dac7202958ebca35cc8b5f3bde057f5e9c40 Mon Sep 17 00:00:00 2001 From: iVy Deliz Date: Thu, 13 Feb 2025 10:49:54 -0700 Subject: [PATCH] Copy & Paste of Activity Directives between Plans (#1625) --- .../activity/ActivityDirectivesTable.svelte | 43 ++++-- .../timeline/TimelineContextMenu.svelte | 47 ++++--- src/utilities/activities.ts | 123 +++++++++--------- src/utilities/clipboard.ts | 26 ++++ src/utilities/sessionStorageClipboard.ts | 7 - 5 files changed, 148 insertions(+), 98 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..ca7490c283 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, @@ -23,6 +23,7 @@ import BulkActionDataGrid from '../ui/DataGrid/BulkActionDataGrid.svelte'; import type DataGrid from '../ui/DataGrid/DataGrid.svelte'; import DataGridActions from '../ui/DataGrid/DataGridActions.svelte'; + import { permissionHandler } from '../../utilities/permissionHandler'; export let activityDirectives: ActivityDirective[] | null = null; export let activityDirectiveErrorRollupsMap: Record | undefined = undefined; @@ -166,14 +167,11 @@ } } - function canPasteActivityDirectives(): boolean { - return plan !== null && hasCreatePermission && canPasteActivityDirectivesFromClipboard(plan); - } - - function pasteActivityDirectives() { - if (plan !== null && canPasteActivityDirectives()) { - const directives = getActivityDirectivesToPaste(plan); - if (directives !== undefined) { + async function pasteActivityDirectives() { + if (plan != null && hasCreatePermission) { + const directivesInClipboard = await getActivityDirectivesClipboardCount(); + if (directivesInClipboard > 0) { + const directives = await getActivityDirectivesToPaste(plan); dispatch(`createActivityDirectives`, directives); } } @@ -214,9 +212,30 @@ Scroll to Activity {/if} - {#if canPasteActivityDirectives()} - {getPasteActivityDirectivesText()} + {#await getActivityDirectivesClipboardCount() then directivesInClipboard} + 0, + permissionError: () => { + if (planReadOnly) { + return PlanStatusMessages.READ_ONLY; + } else if (!hasCreatePermission) { + return 'You do not have permission create activity directives'; + } else if (directivesInClipboard <= 0) { + return 'No activity directives in clipboard'; + } + }, + }, + ], + ]} + on:click={pasteActivityDirectives} + > + {getPasteActivityDirectivesText(directivesInClipboard)} + - {/if} + {/await} diff --git a/src/components/timeline/TimelineContextMenu.svelte b/src/components/timeline/TimelineContextMenu.svelte index b4665296e6..95002755d6 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, @@ -78,6 +78,7 @@ let activityDirectiveSpans: Span[] | null = []; let activityDirectiveStartDate: Date | null = null; let contextMenuComponent: ContextMenu; + let hasCreatePermission: boolean = false; let span: Span | null; let timelines: Timeline[] = []; let hasActivityLayer: boolean = false; @@ -125,6 +126,8 @@ // Explicitly keep track of offsetX because Firefox ends up zeroing it out on the original `contextmenu` MouseEvent $: offsetX = contextMenu?.e.offsetX; + $: hasCreatePermission = plan !== null && featurePermissions.activityDirective.canCreate(user, plan); + function jumpToActivityDirective() { if (span !== null) { const rootSpan = getSpanRootParent(spansMap, span.span_id); @@ -256,19 +259,12 @@ plan !== null && copyActivityDirectivesToClipboard(plan, [activity]); } - function canPasteActivityDirectives(): boolean { - return ( - plan !== null && - featurePermissions.activityDirective.canCreate(user, plan) && - canPasteActivityDirectivesFromClipboard(plan) - ); - } - - 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); + async function pasteActivityDirectivesAtTime(time: Date | false | null) { + if (plan !== null && hasCreatePermission && time instanceof Date) { + const directivesInClipboard = await getActivityDirectivesClipboardCount(); + if (directivesInClipboard > 0) { + const directives = await getActivityDirectivesToPaste(plan, time.getTime()); + await effects.cloneActivityDirectives(directives, plan, user); } } } @@ -432,18 +428,35 @@ > Set Simulation End - {#if canPasteActivityDirectives()} + {#await getActivityDirectivesClipboardCount() then directivesInClipboard} 0, + permissionError: () => { + if ($planReadOnly) { + return PlanStatusMessages.READ_ONLY; + } else if (!hasCreatePermission) { + return 'You do not have permission create activity directives'; + } else if (directivesInClipboard <= 0) { + return 'No activity directives in clipboard'; + } + }, + }, + ], + ]} on:click={() => { if (xScaleView && offsetX !== undefined) { pasteActivityDirectivesAtTime(xScaleView.invert(offsetX)); } }} > - {getPasteActivityDirectivesText()} at Time + {getPasteActivityDirectivesText(directivesInClipboard)} at Time - {/if} + {/await} {/if} {#if span} diff --git a/src/utilities/activities.ts b/src/utilities/activities.ts index 70753f6f5d..8b266116c8 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,90 +191,89 @@ 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 { + if (count <= 0) { + return `Paste Activity Directives`; //generic text, disabled context menu + } else { + return `Paste ${count} Activity Directive${count === 1 ? '' : 's'}`; } } -export function canPasteActivityDirectivesFromClipboard(destinationPlan: Plan | null): boolean { - if (destinationPlan === null) { - return false; - } - +export async function getActivityDirectivesClipboardCount(): Promise { 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; + const clipboardContent = await getClipboardContent(); + if (clipboardContent !== undefined) { + const clipboard = JSON.parse(clipboardContent); + if (clipboard.type === 'aerie_activity_directives' && clipboard.activities !== undefined) { + return clipboard.activities.length; + } } } catch (e) { - console.error(e); - return false; + //eh silent } - - return true; + return -1; } -export function getActivityDirectivesToPaste( +export async function getActivityDirectivesToPaste( destinationPlan: Plan, pasteStartingAtTime?: number, -): ActivityDirective[] | undefined { +): Promise { + let activities: ActivityDirective[] = []; try { - const serializedClipboard = getSessionStorageClipboard(); - if (serializedClipboard !== null) { + const serializedClipboard = await getClipboardContent(); + if (serializedClipboard !== undefined) { const clipboard = JSON.parse(serializedClipboard); - const activities: ActivityDirective[] = clipboard.activities; + activities = 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); + } + }); + + //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. + } - const planStart = getUnixEpochTime(destinationPlan.start_time_doy); - const earliestStart = Math.max(planStart, Math.min(...starts)); //bounded by plan start - const diff = pasteStartingAtTime - earliestStart; + //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); - } + 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) { console.error(e); } + return activities; } diff --git a/src/utilities/clipboard.ts b/src/utilities/clipboard.ts new file mode 100644 index 0000000000..fdbaf47571 --- /dev/null +++ b/src/utilities/clipboard.ts @@ -0,0 +1,26 @@ +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 { + try { + return await window.navigator.clipboard.readText(); + } catch (e) { + console.error(e); + } +} 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`); -}