From 2b276990a13dc07792a3a90f9ef7f0e3fd45f2cb Mon Sep 17 00:00:00 2001 From: iVy Deliz Date: Wed, 18 Dec 2024 20:57:02 -0700 Subject: [PATCH] Copy & Paste of Activity Directives (#1544) --- .../activity/ActivityDirectivesTable.svelte | 66 +++++++++++ .../ActivityDirectivesTablePanel.svelte | 17 ++- .../timeline/TimelineContextMenu.svelte | 51 ++++++++- src/components/timeline/TimelinePanel.svelte | 33 +++++- .../ui/DataGrid/BulkActionDataGrid.svelte | 42 +++++-- src/components/ui/Panel.svelte | 31 ++++- src/utilities/activities.ts | 108 +++++++++++++++++- src/utilities/effects.ts | 62 ++++++++++ src/utilities/gql.ts | 23 ++++ src/utilities/permissions.ts | 13 +++ src/utilities/sessionStorageClipboard.ts | 7 ++ 11 files changed, 435 insertions(+), 18 deletions(-) create mode 100644 src/utilities/sessionStorageClipboard.ts diff --git a/src/components/activity/ActivityDirectivesTable.svelte b/src/components/activity/ActivityDirectivesTable.svelte index 8008fe2ce4..cdc75d6687 100644 --- a/src/components/activity/ActivityDirectivesTable.svelte +++ b/src/components/activity/ActivityDirectivesTable.svelte @@ -17,6 +17,13 @@ import ContextMenuItem from '../context-menu/ContextMenuItem.svelte'; import ContextMenuSeparator from '../context-menu/ContextMenuSeparator.svelte'; import { createEventDispatcher } from 'svelte'; + import { + canPasteActivityDirectivesFromClipboard, + copyActivityDirectivesToClipboard, + getPasteActivityDirectivesText, + getActivityDirectivesToPaste, + } from '../../utilities/activities'; + import { isMetaOrCtrlPressed } from '../../utilities/keyboardEvents'; export let activityDirectives: ActivityDirective[] = []; export let activityDirectiveErrorRollupsMap: Record | undefined = undefined; @@ -31,6 +38,7 @@ export let filterExpression: string = ''; const dispatch = createEventDispatcher<{ + createActivityDirectives: ActivityDirective[]; scrollTimelineToTime: number; }>(); @@ -44,15 +52,22 @@ let activityErrorColumnDef: DataGridColumnDef | null = null; let activityDirectivesWithErrorCounts: ActivityDirectiveWithErrorCounts[] = []; let completeColumnDefs: ColDef[] = columnDefs; + let hasCreatePermission: boolean = false; let hasDeletePermission: boolean = false; let isDeletingDirective: boolean = false; + let showCopyMenu: boolean = true; $: hasDeletePermission = plan !== null ? featurePermissions.activityDirective.canDelete(user, plan) && !planReadOnly : false; + + $: hasCreatePermission = + plan !== null ? featurePermissions.activityDirective.canCreate(user, plan) && !planReadOnly : false; + $: activityDirectivesWithErrorCounts = activityDirectives.map(activityDirective => ({ ...activityDirective, errorCounts: activityDirectiveErrorRollupsMap?.[activityDirective.id]?.errorCounts, })); + $: { activityActionColumnDef = { cellClass: 'action-cell-container', @@ -117,6 +132,22 @@ completeColumnDefs = [activityErrorColumnDef, ...(columnDefs ?? []), activityActionColumnDef]; } + export function onKeyDown(event: KeyboardEvent) { + if (plan !== null && isMetaOrCtrlPressed(event)) { + if (event.key === 'c') { + const activities = getSelectedActivityDirectives(); + if (activities.length > 0) { + copyActivityDirectivesToClipboard(plan, activities); + } + } else if (event.key === 'v') { + const directives = getActivityDirectivesToPaste(plan); + if (directives !== undefined) { + dispatch(`createActivityDirectives`, directives); + } + } + } + } + async function deleteActivityDirective({ id }: ActivityDirective) { if (!isDeletingDirective && plan !== null) { isDeletingDirective = true; @@ -138,6 +169,17 @@ return activityDirective.id; } + export function getSelectedActivityDirectives(): ActivityDirective[] { + const directives: ActivityDirective[] = []; + bulkSelectedActivityDirectiveIds.forEach(id => { + const found = activityDirectives.find(item => item.id === id); + if (found !== null && found !== undefined) { + directives.push(found); + } + }); + return directives; + } + function scrollTimelineToActivityDirective() { const directiveId = bulkSelectedActivityDirectiveIds.length > 0 && bulkSelectedActivityDirectiveIds[0]; const directive = activityDirectives.find(item => item.id === directiveId) ?? null; @@ -145,6 +187,25 @@ dispatch('scrollTimelineToTime', directive.start_time_ms); } } + + function copyActivityDirectives({ detail: activities }: CustomEvent) { + if (plan !== null) { + copyActivityDirectivesToClipboard(plan, activities); + } + } + + function canPasteActivityDirectives(): boolean { + return plan !== null && hasCreatePermission && canPasteActivityDirectivesFromClipboard(plan); + } + + function pasteActivityDirectives() { + if (plan !== null && canPasteActivityDirectives()) { + const directives = getActivityDirectivesToPaste(plan); + if (directives !== undefined) { + dispatch(`createActivityDirectives`, directives); + } + } + } Scroll to Activity {/if} + {#if canPasteActivityDirectives()} + {getPasteActivityDirectivesText()} + {/if} diff --git a/src/components/activity/ActivityDirectivesTablePanel.svelte b/src/components/activity/ActivityDirectivesTablePanel.svelte index a430ff440a..05c432d737 100644 --- a/src/components/activity/ActivityDirectivesTablePanel.svelte +++ b/src/components/activity/ActivityDirectivesTablePanel.svelte @@ -32,6 +32,7 @@ import ActivityTableMenu from './ActivityTableMenu.svelte'; import { get } from 'svelte/store'; import { getTimeRangeAroundTime } from '../../utilities/timeline'; + import effects from '../../utilities/effects'; export let gridSection: ViewGridSection; export let user: User | null; @@ -46,6 +47,7 @@ let derivedColumnDefs: ColDef[] = []; let filterExpression: string = ''; let onGridSizeChangedDebounced = debounce(onGridSizeChanged, 100); + let tableInstance: ActivityDirectivesTable; let viewUpdateActivityDirectivesTableDebounced = debounce(viewUpdateActivityDirectivesTable, 100); $: activityDirectivesTable = $view?.definition.plan.activityDirectivesTable; @@ -246,6 +248,13 @@ dataGrid?.sizeColumnsToFit(); } + function createActivityDirectives({ detail }: CustomEvent) { + const p = get(plan); + if (p !== null) { + effects.cloneActivityDirectives(detail, p, user); + } + } + function onGridSizeChanged() { if (activityDirectivesTable?.autoSizeColumns === 'fill') { autoSizeSpace(); @@ -291,6 +300,10 @@ }, 0); } + function onKeyDownOverPanel({ detail }: CustomEvent) { + tableInstance.onKeyDown(detail); + } + function onColumnMoved() { const columnStates = dataGrid?.getColumnState(); const updatedColumnStates = (columnStates ?? []).filter(columnState => columnState.colId !== 'actions'); @@ -378,7 +391,7 @@ } - +
@@ -412,6 +425,7 @@ @@ -311,6 +343,9 @@ Set Simulation End at Directive Start + activityDirective !== null && copyActivityDirective(activityDirective)}> + Copy Activity Directive + { if (activityDirective !== null) { @@ -329,7 +364,7 @@ ], ]} > - Delete Directive + Delete Activity Directive {:else if span} Jump to Activity Directive @@ -398,6 +433,18 @@ > Set Simulation End + {#if canPasteActivityDirectives()} + + { + if (xScaleView && offsetX !== undefined) { + pasteActivityDirectivesAtTime(xScaleView.invert(offsetX)); + } + }} + > + {getPasteActivityDirectivesText()} at Time + + {/if} {/if} {#if span} diff --git a/src/components/timeline/TimelinePanel.svelte b/src/components/timeline/TimelinePanel.svelte index b30c0cdbac..237665ad88 100644 --- a/src/components/timeline/TimelinePanel.svelte +++ b/src/components/timeline/TimelinePanel.svelte @@ -24,7 +24,7 @@ viewUpdateRow, viewUpdateTimeline, } from '../../stores/views'; - import type { ActivityDirectiveId } from '../../types/activity'; + import type { ActivityDirective, ActivityDirectiveId } from '../../types/activity'; import type { User } from '../../types/app'; import type { ActivityOptions, @@ -41,6 +41,9 @@ import PanelHeaderActions from '../ui/PanelHeaderActions.svelte'; import Timeline from './Timeline.svelte'; import TimelineViewControls from './TimelineViewControls.svelte'; + import { isMetaOrCtrlPressed } from '../../utilities/keyboardEvents'; + import { copyActivityDirectivesToClipboard, getActivityDirectivesToPaste } from '../../utilities/activities'; + import { get } from 'svelte/store'; export let user: User | null; @@ -161,6 +164,32 @@ } } + function getSelectedActivityDirective(): ActivityDirective | undefined { + if ($selectedActivityDirectiveId !== null) { + return $activityDirectivesMap[$selectedActivityDirectiveId]; + } + } + + function onKeyDownOverPanel({ detail }: CustomEvent) { + const p = get(plan); + if (p !== null && isMetaOrCtrlPressed(detail)) { + if (detail.key === 'c') { + const selected = getSelectedActivityDirective(); + if (selected !== undefined) { + const activities: ActivityDirective[] = [selected]; + if (activities.length > 0) { + copyActivityDirectivesToClipboard(p, activities); + } + } + } else if (detail.key === 'v') { + const directives = getActivityDirectivesToPaste(p); + if (directives !== undefined) { + effects.cloneActivityDirectives(directives, p, user); + } + } + } + } + function onUpdateYAxes(event: CustomEvent<{ axes: Axis[]; id: number }>) { const { detail: { axes, id }, @@ -169,7 +198,7 @@ } - +
Timeline
diff --git a/src/components/ui/DataGrid/BulkActionDataGrid.svelte b/src/components/ui/DataGrid/BulkActionDataGrid.svelte index 13efd9996e..dbfd665edd 100644 --- a/src/components/ui/DataGrid/BulkActionDataGrid.svelte +++ b/src/components/ui/DataGrid/BulkActionDataGrid.svelte @@ -5,12 +5,14 @@ // eslint-disable-next-line interface $$Events extends ComponentEvents> { + bulkCopyItems: CustomEvent; bulkDeleteItems: CustomEvent; } + import { browser } from '$app/environment'; import type { ColDef, ColumnState, IRowNode, RedrawRowsParams } from 'ag-grid-community'; import { keyBy } from 'lodash-es'; - import { createEventDispatcher, onDestroy, type ComponentEvents } from 'svelte'; + import { type ComponentEvents, createEventDispatcher, onDestroy } from 'svelte'; import type { User } from '../../../types/app'; import type { Dispatcher } from '../../../types/component'; import type { RowId, TRowData } from '../../../types/data-grid'; @@ -35,6 +37,7 @@ export let selectedItemId: RowId | null = null; export let selectedItemIds: RowId[] = []; export let showContextMenu: boolean = true; + export let showCopyMenu: boolean = false; export let singleItemDisplayText: string = ''; export let suppressDragLeaveHidesColumns: boolean = true; export let suppressRowClickSelection: boolean = false; @@ -89,23 +92,33 @@ onDestroy(() => onBlur()); + function bulkCopyItems() { + const selectedRows = getRowDataFromSelectedItems(); + if (selectedRows.length) { + dispatch('bulkCopyItems', selectedRows); + } + } + function bulkDeleteItems() { if (deletePermission) { - const selectedItemIdsMap = keyBy(selectedItemIds); - const selectedRows: RowData[] = items.reduce((selectedRows: RowData[], row: RowData) => { - const id = getRowId(row); - if (selectedItemIdsMap[id] !== undefined) { - selectedRows.push(row); - } - return selectedRows; - }, []); - + const selectedRows = getRowDataFromSelectedItems(); if (selectedRows.length) { dispatch('bulkDeleteItems', selectedRows); } } } + function getRowDataFromSelectedItems(): RowData[] { + const selectedItemIdsMap = keyBy(selectedItemIds); + return items.reduce((selectedRows: RowData[], row: RowData) => { + const id = getRowId(row); + if (selectedItemIdsMap[id] !== undefined) { + selectedRows.push(row); + } + return selectedRows; + }, []); + } + function onBlur() { if (browser) { document.removeEventListener('keydown', onKeyDown); @@ -172,13 +185,20 @@ > {#if showContextMenu} - Bulk Actions Select All {isFiltered ? 'Visible ' : ''}{pluralItemDisplayText} + {#if selectedItemIds.length} + {#if showCopyMenu} + + Copy {selectedItemIds.length} + {selectedItemIds.length > 1 ? pluralItemDisplayText : singleItemDisplayText} + + {/if} + + import { createEventDispatcher } from 'svelte'; + export let borderLeft: boolean = false; export let borderRight: boolean = false; export let borderTop: boolean = false; export let overflowYBody: 'visible' | 'hidden' | 'clip' | 'scroll' | 'auto' = 'auto'; export let padBody: boolean = true; export let padHeader: boolean = true; + let div: HTMLDivElement; + + const dispatch = createEventDispatcher<{ + onKeyDownOverPanel: KeyboardEvent; + }>(); + + function onMouseEnter() { + div.addEventListener(`keydown`, onKeyDownOverPanel); + } + + function onMouseLeave() { + div.removeEventListener(`keydown`, onKeyDownOverPanel); + } + + function onKeyDownOverPanel(event: KeyboardEvent) { + dispatch(`onKeyDownOverPanel`, event); + } -
+
diff --git a/src/utilities/activities.ts b/src/utilities/activities.ts index 46d8f4362f..9d4011a67d 100644 --- a/src/utilities/activities.ts +++ b/src/utilities/activities.ts @@ -4,7 +4,9 @@ import type { ActivityMetadata, ActivityMetadataKey, ActivityMetadataValue } fro import type { Plan } from '../types/plan'; import type { Span, SpanId, SpanUtilityMaps, SpansMap } from '../types/simulation'; import { compare, isEmpty } from './generic'; -import { getActivityDirectiveStartTimeMs, getIntervalInMs } from './time'; +import { getActivityDirectiveStartTimeMs, getDoyTime, getIntervalFromDoyRange, getIntervalInMs } from './time'; +import { getSessionStorageClipboard, setSessionStorageClipboard } from './sessionStorageClipboard'; +import { showSuccessToast } from './toast'; /** * Updates activity metadata with a new key/value and removes any empty values. @@ -163,3 +165,107 @@ export function preprocessActivityDirectiveDB( } return { ...activityDirectiveDB, start_time_ms }; } + +export function copyActivityDirectivesToClipboard(sourcePlan: Plan, activities: ActivityDirective[]) { + const copiedActivityIds = new Set(activities.map(a => a.id)); + const clippedActivities = activities.map(activity => { + const anchorInSelection = activity.anchor_id !== null && copiedActivityIds.has(activity.anchor_id); + return { + anchor_id: anchorInSelection ? activity.anchor_id : null, + anchored_to_start: activity.anchored_to_start, + arguments: activity.arguments, + id: activity.id, + name: activity.name, + start_offset: activity.start_offset, + start_time_ms: activity.start_time_ms, + tags: activity.tags, + type: activity.type, + }; + }); + + const clipboard = { + activities: clippedActivities, + sourcePlanId: sourcePlan.id, + }; + + setSessionStorageClipboard(JSON.stringify(clipboard)); + showSuccessToast(`Copied ${activities.length} Activity Directive${activities.length === 1 ? '' : 's'}`); +} + +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 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 function getActivityDirectivesToPaste( + destinationPlan: Plan, + pasteStartingAtTime?: number, +): ActivityDirective[] | undefined { + try { + const serializedClipboard = getSessionStorageClipboard(); + 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 => { + if (a.start_time_ms !== null) { + starts.push(a.start_time_ms); + } + }); + const earliestStart = Math.min(...starts); + const 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) { + console.error(e); + } +} diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index cbfd4327bb..877dc8f53a 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -473,6 +473,68 @@ const effects = { } }, + async cloneActivityDirectives( + activities: ActivityDirective[], + plan: Plan, + user: User | null, + ): Promise { + try { + if (plan === null) { + throw Error(`Plan is not defined`); + } + if (!queryPermissions.CREATE_ACTIVITY_DIRECTIVE(user, plan)) { + throwPermissionError('clone activity directives into the plan'); + } + + const activityRemap: Record = {}; + const activityDirectivesInsertInput = activities.map( + ({ anchored_to_start, arguments: activityArguments, metadata, name, start_offset, type }) => { + const activityDirectiveInsertInput: ActivityDirectiveInsertInput = { + anchor_id: null, + anchored_to_start, + arguments: activityArguments, + metadata, + name, + plan_id: plan.id, + start_offset, + type, + }; + return activityDirectiveInsertInput; + }, + ); + + const response = await reqHasura<{ returning: ActivityDirectiveDB[] }>( + gql.CREATE_ACTIVITY_DIRECTIVES, + { activityDirectivesInsertInput }, + user, + ); + + // re-anchor activity directive clones + const { insert_activity_directive: createdActivities } = response; + if (createdActivities !== null) { + const { returning: clonedActivitiesReferences } = createdActivities; + clonedActivitiesReferences.forEach((directive, index) => { + const { id } = activities[index]; + activityRemap[id] = directive.id; + }); + + const anchorUpdates = activities + .filter(({ anchor_id: anchorId }) => anchorId !== null) + .map(({ anchor_id: anchorId, id }) => ({ + _set: { anchor_id: activityRemap[anchorId as number] }, + where: { id: { _eq: activityRemap[id] }, plan_id: { _eq: (plan as PlanSchema).id } }, + })); + + await reqHasura(gql.UPDATE_ACTIVITY_DIRECTIVES, { updates: anchorUpdates }, user); + showSuccessToast(`Pasted ${activities.length} Activity Directive${activities.length === 1 ? '' : 's'}`); + return clonedActivitiesReferences; + } + } catch (e) { + catchError('Activity Directive Paste Failed', e as Error); + showFailureToast('Activity Directive Paste Failed'); + } + }, + async createActivityDirective( argumentsMap: ArgumentsMap, start_time_doy: string, diff --git a/src/utilities/gql.ts b/src/utilities/gql.ts index 51b39e758c..75d039ded8 100644 --- a/src/utilities/gql.ts +++ b/src/utilities/gql.ts @@ -104,6 +104,7 @@ export enum Queries { GET_SEQUENCE_SEQ_JSON = 'getSequenceSeqJson', GET_USER_SEQUENCE_SEQ_JSON = 'getUserSequenceSeqJson', INSERT_ACTIVITY_DIRECTIVE = 'insert_activity_directive_one', + INSERT_ACTIVITY_DIRECTIVES = 'insert_activity_directive', INSERT_ACTIVITY_DIRECTIVE_TAGS = 'insert_activity_directive_tags', INSERT_ACTIVITY_PRESET = 'insert_activity_presets_one', INSERT_CHANNEL_DICTIONARY = 'insert_channel_dictionary_one', @@ -197,6 +198,7 @@ export enum Queries { TAGS = 'tags', TOPIC = 'topic', UPDATE_ACTIVITY_DIRECTIVE = 'update_activity_directive_by_pk', + UPDATE_ACTIVITY_DIRECTIVES = 'update_activity_directive_many', UPDATE_ACTIVITY_PRESET = 'update_activity_presets_by_pk', UPDATE_CONSTRAINT_METADATA = 'update_constraint_metadata_by_pk', UPDATE_CONSTRAINT_SPECIFICATION = 'update_constraint_specification_by_pk', @@ -334,6 +336,17 @@ const gql = { } `, + CREATE_ACTIVITY_DIRECTIVES: `#graphql + mutation CreateActivityDirectives($activityDirectivesInsertInput: [activity_directive_insert_input!]!) { + ${Queries.INSERT_ACTIVITY_DIRECTIVES}(objects: $activityDirectivesInsertInput) { + returning { + id + type + } + } + } + `, + CREATE_ACTIVITY_DIRECTIVE_TAGS: `#graphql mutation CreateActivityDirectiveTags($tags: [activity_directive_tags_insert_input!]!) { ${Queries.INSERT_ACTIVITY_DIRECTIVE_TAGS}(objects: $tags, on_conflict: { @@ -3457,6 +3470,16 @@ const gql = { } `, + UPDATE_ACTIVITY_DIRECTIVES: `#graphql + mutation UpdateActivityDirective($updates: [activity_directive_updates!]!) { + ${Queries.UPDATE_ACTIVITY_DIRECTIVES}( + updates: $updates + ) { + affected_rows + } + } + `, + UPDATE_ACTIVITY_PRESET: `#graphql mutation UpdateActivityPreset($id: Int!, $activityPresetSetInput: activity_presets_set_input!) { ${Queries.UPDATE_ACTIVITY_PRESET}( diff --git a/src/utilities/permissions.ts b/src/utilities/permissions.ts index 1a0fa57992..684a90e342 100644 --- a/src/utilities/permissions.ts +++ b/src/utilities/permissions.ts @@ -319,6 +319,12 @@ const queryPermissions: Record b isUserAdmin(user) || (getPermission(queries, user) && (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) ); }, + CREATE_ACTIVITY_DIRECTIVES: (user: User | null, plan: PlanWithOwners): boolean => { + const queries = [Queries.INSERT_ACTIVITY_DIRECTIVES]; + return ( + isUserAdmin(user) || (getPermission(queries, user) && (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) + ); + }, CREATE_ACTIVITY_DIRECTIVE_TAGS: (user: User | null): boolean => { return isUserAdmin(user) || getPermission([Queries.INSERT_ACTIVITY_DIRECTIVE_TAGS], user); }, @@ -932,6 +938,13 @@ const queryPermissions: Record b (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) ); }, + UPDATE_ACTIVITY_DIRECTIVES: (user: User | null, plan: PlanWithOwners): boolean => { + return ( + isUserAdmin(user) || + (getPermission([Queries.UPDATE_ACTIVITY_DIRECTIVES], user) && + (isPlanOwner(user, plan) || isPlanCollaborator(user, plan))) + ); + }, UPDATE_ACTIVITY_PRESET: (user: User | null, preset: AssetWithOwner): boolean => { return isUserAdmin(user) || (getPermission([Queries.UPDATE_ACTIVITY_PRESET], user) && isUserOwner(user, preset)); }, diff --git a/src/utilities/sessionStorageClipboard.ts b/src/utilities/sessionStorageClipboard.ts new file mode 100644 index 0000000000..4c112419e2 --- /dev/null +++ b/src/utilities/sessionStorageClipboard.ts @@ -0,0 +1,7 @@ +export function setSessionStorageClipboard(content: string) { + sessionStorage.setItem(`aerie_clipboard`, content); +} + +export function getSessionStorageClipboard(): string | null { + return sessionStorage.getItem(`aerie_clipboard`); +}