diff --git a/dataweaver/apps/web/src/components/primitives/icons/close.tsx b/dataweaver/apps/web/src/components/primitives/icons/close.tsx index 84a8ef49..eb261430 100644 --- a/dataweaver/apps/web/src/components/primitives/icons/close.tsx +++ b/dataweaver/apps/web/src/components/primitives/icons/close.tsx @@ -11,9 +11,7 @@ export const IconClose = (props: ComponentPropsWithRef<'svg'>) => { > ); diff --git a/dataweaver/apps/web/src/components/primitives/icons/select.tsx b/dataweaver/apps/web/src/components/primitives/icons/select.tsx new file mode 100644 index 00000000..a5c23308 --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/select.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconSelect = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/shapes.tsx b/dataweaver/apps/web/src/components/primitives/icons/shapes.tsx new file mode 100644 index 00000000..f8d1b3fd --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/shapes.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconShapes = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/scopes/atlas/atlas_provider.tsx b/dataweaver/apps/web/src/components/scopes/atlas/atlas_provider.tsx index a0828881..e55f0134 100644 --- a/dataweaver/apps/web/src/components/scopes/atlas/atlas_provider.tsx +++ b/dataweaver/apps/web/src/components/scopes/atlas/atlas_provider.tsx @@ -11,6 +11,7 @@ import { } from 'react'; import { createShapeId, type Editor, type TLShapeId, Tldraw } from 'tldraw'; import s from './atlas_provider.module.scss'; +import { ExportProvider } from './components/in_front_of_canvas/export/export_provider'; import { ATLAS_COMPONENTS, ATLAS_OVERLAYS, @@ -141,15 +142,17 @@ export const AtlasProvider = ({ children }: AtlasProviderProps) => { return ( - - {children} + + + {children} + ); }; diff --git a/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/controls.tsx b/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/controls.tsx index 7f4fda7e..9022c460 100644 --- a/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/controls.tsx +++ b/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/controls.tsx @@ -1,6 +1,5 @@ import { useEditor, useValue } from 'tldraw'; import { Button } from '~/components/elements/button'; -import { toast } from '~/components/foundations/toaster/store'; import { IconExport } from '~/components/primitives/icons/export'; import { IconMinus } from '~/components/primitives/icons/minus'; import { IconPlus } from '~/components/primitives/icons/plus'; @@ -11,6 +10,7 @@ import { } from '~/components/scopes/atlas/config'; import { mapRange } from '~/functions/map_range'; import s from './controls.module.scss'; +import { useExport } from './export/export_provider'; const BUTTON_EXPORT_COLOR_SCHEME = { base: 'var(--color-control-surface)', @@ -33,6 +33,8 @@ const BUTTON_ZOOM_COLOR_SCHEME = { export const Controls = () => { const editor = useEditor(); + const { isOpen, toggle } = useExport(); + const zoom = useValue('zoom', () => editor.getZoomLevel(), [editor]); // Rescale the actual zoom from tldraw's enforced min / max (the first and @@ -74,13 +76,9 @@ export const Controls = () => { size="large" className={s['button-export']} colorScheme={BUTTON_EXPORT_COLOR_SCHEME} - // TODO: Support export here - onClick={() => - toast( - 'Controls export not supported yet', - 'This feature will be coming in a future release. Stay tuned!', - ) - } + aria-haspopup="dialog" + aria-expanded={isOpen} + onClick={toggle} > Export diff --git a/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/export/export_panel.module.scss b/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/export/export_panel.module.scss new file mode 100644 index 00000000..fdfd7e53 --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/export/export_panel.module.scss @@ -0,0 +1,136 @@ +.container { + --controls-height-with-gutters: calc( + var(--controls-height) + var(--controls-gutter-outer) * 2 + ); + + position: absolute; + top: var(--controls-height-with-gutters); + + // Note: We remove 2px here to avoid tools shadow appearing at edge of this + right: calc(var(--tools-gutter-outer) - 2px); + display: flex; + flex-direction: column; + width: 360px; + padding: 20px; + pointer-events: auto; + background: rgb(var(--color-control-surface)); + border-radius: 16px; + box-shadow: var(--shadow-elevated); + backdrop-filter: blur(40px); +} + +.header-container { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + + .title { + @include type-title(500); + + color: rgb(var(--color-control-content)); + } + + .button-close { + margin: -4px -10px -4px 0; + } +} + +.statuses-container { + height: 148px; + background: rgb(var(--color-control-surface-raised)); + border-radius: 8px; + + .status-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 8px 20px; + text-align: center; + + .status-icon { + width: 24px; + height: 24px; + color: rgb(var(--color-control-content)); + } + + .status-title { + @include type-title(500); + + margin-top: 8px; + color: rgb(var(--color-control-content)); + } + + .status-description { + @include type-body-small; + + color: rgb(var(--color-control-content-muted)); + } + + .status-button-select-all { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 16px; + + @include hover { + text-decoration: underline; + } + + .select-all-label { + @include type-body-small(500); + + color: rgb(var(--color-control-content)); + } + + .select-all-keys { + @include type-body-small; + + padding: 1px 4px; + color: rgb(var(--color-control-content-muted)); + border-radius: 4px; + } + } + } +} + +.content-container { + display: flex; + flex-direction: column; + margin-top: 30px; + + .options-title { + @include type-title(500); + + padding-bottom: 8px; + color: rgb(var(--color-control-content-muted)); + border-bottom: 1px solid rgb(var(--color-surface-decorator)); + } + + .options-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px 32px; + margin-top: 18px; + + .option-container { + display: flex; + flex-direction: column; + gap: 8px; + + .option-title { + @include type-body-small(500); + + color: rgb(var(--color-control-content)); + } + + .option-description { + @include type-body-small; + + color: rgb(var(--color-control-content-muted)); + } + } + } +} diff --git a/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/export/export_panel.tsx b/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/export/export_panel.tsx new file mode 100644 index 00000000..799e2560 --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/export/export_panel.tsx @@ -0,0 +1,208 @@ +import { EASE_OUT } from '@package/tokens/ts'; +import { AnimatePresence, m } from 'motion/react'; +import { useRef } from 'react'; +import { useEditor, useValue } from 'tldraw'; +import { Button } from '~/components/elements/button'; +import { IconClose } from '~/components/primitives/icons/close'; +import { IconSelect } from '~/components/primitives/icons/select'; +import { IconShapes } from '~/components/primitives/icons/shapes'; +import { IS_APPLE } from '~/configs/environment_client'; +import { useFocusTrap } from '~/hooks/use_focus_trap'; +import { useKeydown } from '~/hooks/use_keydown'; +import { useMatchMedia } from '~/hooks/use_match_media'; +import s from './export_panel.module.scss'; +import { useExport } from './export_provider'; + +/** The available export formats shown in the panel's options grid. */ +const EXPORT_OPTIONS = [ + { + title: 'API Request Code', + description: + 'Your JSON code will appear here once you add content to your canvas.', + }, + { + title: 'CSV', + description: + "You'll get a separate file for every card that contains data.", + }, + { + title: 'AI Narrative', + description: 'Gemini will summarize your canvas into a narrative.', + }, + { + title: 'AI Infographic', + description: 'Gemini can generate a visual infographic from your canvas.', + }, + { + title: 'SVG', + description: + "You'll receive a separated vector svg file for each of your selected cards.", + }, + { + title: 'PNG', + description: + "You'll receive a separated raster png file for each of your selected cards.", + }, +] as const; + +export const ExportPanel = () => { + const editor = useEditor(); + + const { isOpen, close } = useExport(); + + const containerRef = useRef(null); + + const prefersMotion = useMatchMedia('prefers-motion'); + + const cardCount = useValue('atlas-card-count', () => { + return editor + .getCurrentPageShapes() + .filter((shape) => shape.type === 'card').length; + }, [editor]); + + const selectedCount = useValue('atlas-selected-card-count', () => { + const selectedCards = editor.getSelectedShapeIds().filter((id) => { + const shape = editor.getShape(id); + return shape ? shape.type === 'card' : false; + }); + return selectedCards.length; + }, [editor]); + + const status = + cardCount === 0 ? 'empty' : selectedCount === 0 ? 'none-selected' : 'ready'; + + const selectAllCards = () => { + const cardIds = editor + .getCurrentPageShapes() + .filter((shape) => shape.type === 'card') + .map((shape) => shape.id); + editor.select(...cardIds); + }; + + useKeydown('Escape', close, { isEnabled: isOpen }); + + // TODO: For now this doesn't seem to really work due to TLDraw consuming + // tab events. Review focus trap implementation once we review how TLDraw + // handles focus and keyboard events in general, and adjust as needed + useFocusTrap(containerRef, { isEnabled: isOpen }); + + return ( + + {isOpen && ( + event.stopPropagation()} + onWheelCapture={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + {...(prefersMotion && { + initial: { opacity: 0, transform: 'translateY(-8px)' }, + animate: { opacity: 1, transform: 'translateY(0px)' }, + exit: { opacity: 0, transform: 'translateY(-8px)' }, + transition: { duration: 0.3, ease: EASE_OUT }, + })} + > +
+

Export

+ +
+ +
+ + + {status === 'empty' && ( + <> + +

+ Create a prompt to begin +

+

+ Once you have data to export, come back to this window. +

+ + )} + + {status === 'none-selected' && ( + <> + +

No cards selected

+

+ Please select 1 or more cards to see export options. +

+ + + )} + + {status === 'ready' && ( + <> + +

+ {selectedCount} {selectedCount === 1 ? 'card ' : 'cards '} + selected +

+

+ Choose a format below to export your selection. +

+ + )} +
+
+
+ +
+

Export options

+ +
    + {EXPORT_OPTIONS.map((option) => ( +
  • +

    {option.title}

    +

    + {option.description} +

    +
  • + ))} +
+
+
+ )} +
+ ); +}; diff --git a/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/export/export_provider.tsx b/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/export/export_provider.tsx new file mode 100644 index 00000000..5ab89c83 --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/export/export_provider.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { + createContext, + type ReactNode, + useContext, + useMemo, + useState, +} from 'react'; + +interface ExportActionsContextProps { + open(): void; + close(): void; + toggle(): void; +} + +const ExportIsOpenContext = createContext(false); +const ExportActionsContext = createContext( + null, +); + +interface ExportProviderProps { + children: ReactNode; +} + +export const ExportProvider = ({ children }: ExportProviderProps) => { + const [isOpen, setIsOpen] = useState(false); + + // Keep the actions stable so consumers that only need them (e.g. cards on the + // canvas) don't re-render when 'isOpen' changes + const actions = useMemo( + () => ({ + open: () => setIsOpen(true), + close: () => setIsOpen(false), + toggle: () => setIsOpen((isOpen) => !isOpen), + }), + [], + ); + + return ( + + + {children} + + + ); +}; + +/** Read whether the export panel is open — must be used inside ``. */ +export const useExportIsOpen = (): boolean => useContext(ExportIsOpenContext); + +/** Read the export actions — must be used inside ``. */ +export const useExportActions = (): ExportActionsContextProps => { + const context = useContext(ExportActionsContext); + if (!context) { + throw new Error("'useExportActions' must be used within 'ExportProvider'."); + } + + return context; +}; + +/** Read the export state and actions together — re-renders when `isOpen` changes. */ +export const useExport = () => { + const isOpen = useExportIsOpen(); + const actions = useExportActions(); + + return useMemo(() => ({ isOpen, ...actions }), [isOpen, actions]); +}; diff --git a/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/index.tsx b/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/index.tsx index 5ade05e2..3771de1b 100644 --- a/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/index.tsx +++ b/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/index.tsx @@ -1,4 +1,5 @@ import { Controls } from './controls'; +import { ExportPanel } from './export/export_panel'; import { Selection } from './selection'; import { Tools } from './tools'; @@ -7,6 +8,7 @@ export const InFrontOfTheCanvas = () => { <> + ); diff --git a/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/selection.tsx b/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/selection.tsx index d79ce0d3..8c0f5c0f 100644 --- a/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/selection.tsx +++ b/dataweaver/apps/web/src/components/scopes/atlas/components/in_front_of_canvas/selection.tsx @@ -8,6 +8,7 @@ import { IconBarChart } from '~/components/primitives/icons/bar_chart'; import { IconDelete } from '~/components/primitives/icons/delete'; import { IconExport } from '~/components/primitives/icons/export'; import { useMatchMedia } from '~/hooks/use_match_media'; +import { useExportActions } from './export/export_provider'; import s from './selection.module.scss'; /** Screen-space margin the selection box extends past the cards, in pixels. */ @@ -25,6 +26,7 @@ const BUTTON_COLOR_SCHEME = { export const Selection = () => { const editor = useEditor(); + const { open: openExport } = useExportActions(); const containerRef = useRef(null); @@ -89,7 +91,7 @@ export const Selection = () => { colorScheme={BUTTON_COLOR_SCHEME} // Prevent tldraw from treating the press as a canvas gesture onPointerDown={(event) => event.stopPropagation()} - // TODO: Support export here + // TODO: Support chart options here onClick={() => toast( 'Selection chart options not supported yet', @@ -103,12 +105,7 @@ export const Selection = () => { aria-label="Export" colorScheme={BUTTON_COLOR_SCHEME} onPointerDown={(event) => event.stopPropagation()} - onClick={() => - toast( - 'Selection export not supported yet', - 'This feature will be coming in a future release. Stay tuned!', - ) - } + onClick={openExport} />