diff --git a/src/components/modal/ExportClipboardModal.tsx b/src/components/modal/ExportClipboardModal.tsx new file mode 100644 index 0000000..55e108b --- /dev/null +++ b/src/components/modal/ExportClipboardModal.tsx @@ -0,0 +1,140 @@ +import { + Checkbox, + Dialog, + DialogBody, + DialogFooter, + Position, + OverlayToaster, +} from '@blueprintjs/core'; +import styled from '@emotion/styled'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { Button } from 'react-science/ui'; + +import useLog from '../../hooks/useLog'; +import useModal from '../../hooks/useModal'; +import useOriginalFilteredROIs from '../../hooks/useOriginalFilteredROIs'; +import { saveToClipboard } from '../../utils/export'; +import { useMergeToImage } from '../tool/ExportTool'; + +import StyledModalBody from './utils/StyledModalBody'; + +const ExportStyle = styled.div` + display: flex; + flex-direction: column; + width: 500px; + + .header { + font-size: 1.25rem; + } + + .container { + padding: 20px; + } +`; + +const SaveButtonInner = styled.span` + display: flex; + align-items: center; +`; + +const AppToaster = OverlayToaster.create({ + position: Position.TOP, +}); + +type ExportClipboardModalProps = { + previewImageIdentifier: string; +}; + +function ExportClipboardModal({ + previewImageIdentifier, +}: ExportClipboardModalProps) { + const { isOpen, close } = useModal('exportClipboard'); + const { logger } = useLog(); + + const ROIs = useOriginalFilteredROIs(previewImageIdentifier); + const hasAnnotations = ROIs.length > 0; + + const defaultFormState = useMemo( + () => ({ + annotations: true, + }), + [], + ); + + const [formState, setFormState] = useState(defaultFormState); + const resetForm = useCallback( + () => setFormState(defaultFormState), + [setFormState, defaultFormState], + ); + + const mergeToImage = useMergeToImage(formState.annotations); + + const copyToClipboard = useCallback(() => { + return mergeToImage().then(({ toSave }) => saveToClipboard(toSave)); + }, [mergeToImage]); + + const save = useCallback(() => { + copyToClipboard() + .catch((error) => { + logger.error(`Failed to copy to clipboard: ${error}`); + AppToaster.show({ + message: 'Failed to copy to clipboard', + intent: 'danger', + timeout: 1500, + }); + }) + .finally(() => { + resetForm(); + close(); + }); + AppToaster.show({ + message: 'Copied successfully !', + intent: 'success', + timeout: 1500, + }); + }, [close, copyToClipboard, logger, resetForm]); + + if (!hasAnnotations) { + save(); + return null; + } + + return ( + + + + +
+ + setFormState({ + ...formState, + annotations: e.target.checked, + }) + } + /> +
+
+
+ + Copy + + } + /> +
+
+ ); +} + +export default memo(ExportClipboardModal); diff --git a/src/components/modal/ExportModal.tsx b/src/components/modal/ExportPixeliumModal.tsx similarity index 95% rename from src/components/modal/ExportModal.tsx rename to src/components/modal/ExportPixeliumModal.tsx index cb57271..7f00c48 100644 --- a/src/components/modal/ExportModal.tsx +++ b/src/components/modal/ExportPixeliumModal.tsx @@ -38,8 +38,8 @@ const SaveButtonInner = styled.span` align-items: center; `; -function ExportModal() { - const { isOpen, close } = useModal('export'); +function ExportPixeliumModal() { + const { isOpen, close } = useModal('exportPixelium'); const data = useData(); const preferences = usePreferences(); const view = useView(); @@ -48,9 +48,9 @@ function ExportModal() { const defaultFormState = useMemo( () => ({ name: '', - view: false, - preferences: false, - data: false, + view: true, + preferences: true, + data: true, }), [], ); @@ -158,4 +158,4 @@ function ExportModal() { ); } -export default memo(ExportModal); +export default memo(ExportPixeliumModal); diff --git a/src/components/modal/ExportPngModal.tsx b/src/components/modal/ExportPngModal.tsx new file mode 100644 index 0000000..e4fab5f --- /dev/null +++ b/src/components/modal/ExportPngModal.tsx @@ -0,0 +1,150 @@ +import { + Checkbox, + InputGroup, + FormGroup, + Dialog, + DialogBody, + DialogFooter, +} from '@blueprintjs/core'; +import styled from '@emotion/styled'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { Button } from 'react-science/ui'; + +import useCurrentTab from '../../hooks/useCurrentTab'; +import useData from '../../hooks/useData'; +import useLog from '../../hooks/useLog'; +import useModal from '../../hooks/useModal'; +import useOriginalFilteredROIs from '../../hooks/useOriginalFilteredROIs'; +import { saveAsPng } from '../../utils/export'; +import { useMergeToImage } from '../tool/ExportTool'; + +import StyledModalBody from './utils/StyledModalBody'; + +const ExportStyle = styled.div` + display: flex; + flex-direction: column; + width: 500px; + + .header { + font-size: 1.25rem; + } + + .container { + padding: 20px; + } +`; + +const SaveButtonInner = styled.span` + display: flex; + align-items: center; +`; + +type ExportPngModalProps = { + previewImageIdentifier: string; +}; + +function ExportPngModal({ previewImageIdentifier }: ExportPngModalProps) { + const { isOpen, close } = useModal('exportPng'); + const currentTab = useCurrentTab(); + const data = useData(); + + const ROIs = useOriginalFilteredROIs(previewImageIdentifier); + const hasAnnotations = ROIs.length > 0; + + const { logger } = useLog(); + + const title = useMemo(() => { + let fullTitle; + if (currentTab) { + fullTitle = data.images[currentTab]?.metadata.name || currentTab; + } else { + fullTitle = 'unnamed'; + } + return fullTitle.split('.')[0]; + }, [currentTab, data]); + + const defaultFormState = useMemo( + () => ({ + name: '', + annotations: true, + }), + [], + ); + + const [formState, setFormState] = useState(defaultFormState); + const resetForm = useCallback( + () => setFormState(defaultFormState), + [setFormState, defaultFormState], + ); + + const mergeToImage = useMergeToImage(formState.annotations); + + const exportPNG = useCallback(async () => { + return mergeToImage().then(({ toSave }) => + saveAsPng(toSave, formState.name || title), + ); + }, [formState.name, mergeToImage, title]); + + const save = useCallback(() => { + exportPNG() + .catch((error) => logger.error(`Failed to generate PNG: ${error}`)) + .finally(() => { + resetForm(); + close(); + }); + }, [close, exportPNG, logger, resetForm]); + + return ( + + + + +
+ + + setFormState({ + ...formState, + name: e.target.value, + }) + } + /> + + {hasAnnotations ? ( + + setFormState({ + ...formState, + annotations: e.target.checked, + }) + } + /> + ) : null} +
+
+
+ + Save + + } + /> +
+
+ ); +} + +export default memo(ExportPngModal); diff --git a/src/components/modal/ModalContainer.tsx b/src/components/modal/ModalContainer.tsx index 373b8d5..0708ab9 100644 --- a/src/components/modal/ModalContainer.tsx +++ b/src/components/modal/ModalContainer.tsx @@ -4,7 +4,9 @@ import useCurrentTab from '../../hooks/useCurrentTab'; import useView from '../../hooks/useView'; import AboutModal from './AboutModal'; -import ExportModal from './ExportModal'; +import ExportClipboardModal from './ExportClipboardModal'; +import ExportPixeliumModal from './ExportPixeliumModal'; +import ExportPngModal from './ExportPngModal'; import ExtractROIModal from './ExtractROIModal'; import LogModal from './LogModal'; import BlurModal from './preview/filters/BlurModal'; @@ -74,7 +76,13 @@ function ModalContainer() { {view.modals.close && ( )} - {view.modals.export && } + {view.modals.exportPng && ( + + )} + {view.modals.exportClipboard && ( + + )} + {view.modals.exportPixelium && } {view.modals.resize && ( )} diff --git a/src/components/tool/ExportTool.tsx b/src/components/tool/ExportTool.tsx index b7bb43f..6f035cc 100644 --- a/src/components/tool/ExportTool.tsx +++ b/src/components/tool/ExportTool.tsx @@ -5,22 +5,38 @@ import { FaFileExport } from 'react-icons/fa'; import { Toolbar, ToolbarItemProps } from 'react-science/ui'; import useAnnotationRef from '../../hooks/useAnnotationRef'; -import useCurrentTab from '../../hooks/useCurrentTab'; import useImage from '../../hooks/useImage'; -import useLog from '../../hooks/useLog'; import useModal from '../../hooks/useModal'; -import { - saveAsPng, - saveToClipboard, - svgElementToImage, -} from '../../utils/export'; +import { svgElementToImage } from '../../utils/export'; -function ExportTool() { - const currentTab = useCurrentTab(); +export function useMergeToImage(hasAnnotations: boolean) { const { pipelined } = useImage(); - const { logger } = useLog(); const { svgRef } = useAnnotationRef(); - const { open: openExportModal } = useModal('export'); + + return async () => { + const svgElement = svgRef.current; + const annotations = await svgElementToImage(svgElement, { + width: pipelined.width, + height: pipelined.height, + }); + const recolored = + pipelined.colorModel === ImageColorModel.RGBA + ? (pipelined as Image) + : pipelined.convertColor(ImageColorModel.RGBA); + const toSave = + annotations !== null && hasAnnotations + ? annotations.copyTo(recolored) + : pipelined.colorModel !== ImageColorModel.BINARY + ? (pipelined as Image) + : recolored; + return { toSave, annotations }; + }; +} + +function ExportTool() { + const { open: openExportPngModal } = useModal('exportPng'); + const { open: openExportClipboardModal } = useModal('exportClipboard'); + const { open: openExportPixeliumModal } = useModal('exportPixelium'); const exportItem: ToolbarItemProps = { id: 'export', @@ -61,48 +77,17 @@ function ExportTool() { ); - const mergeToImage = useCallback(async () => { - const svgElement = svgRef.current; - const annotations = await svgElementToImage(svgElement, { - width: pipelined.width, - height: pipelined.height, - }); - const recolored = - pipelined.colorModel === ImageColorModel.RGBA - ? (pipelined as Image) - : pipelined.convertColor(ImageColorModel.RGBA); - const toSave = - annotations === null - ? (pipelined as Image) - : annotations.copyTo(recolored); - return toSave; - }, [pipelined, svgRef]); - - const exportPNG = useCallback(async () => { - return mergeToImage().then((toSave) => - saveAsPng(toSave, `${currentTab || 'unnamed'}.png`), - ); - }, [currentTab, mergeToImage]); - - const copyToClipboard = useCallback(() => { - return mergeToImage().then((toSave) => saveToClipboard(toSave)); - }, [mergeToImage]); - const handleSelect = useCallback( (selected) => { if (selected.data === 'png') { - exportPNG().catch((error) => - logger.error(`Failed to generate PNG: ${error}`), - ); + openExportPngModal(); } else if (selected.data === 'clipboard') { - copyToClipboard().catch((error) => - logger.error(`Failed to copy to clipboard: ${error}`), - ); + openExportClipboardModal(); } else if (selected.data === 'pixelium') { - openExportModal(); + openExportPixeliumModal(); } }, - [copyToClipboard, exportPNG, logger, openExportModal], + [openExportPngModal, openExportClipboardModal, openExportPixeliumModal], ); return ; diff --git a/src/state/view/ViewReducer.ts b/src/state/view/ViewReducer.ts index c7837fd..c7d1551 100644 --- a/src/state/view/ViewReducer.ts +++ b/src/state/view/ViewReducer.ts @@ -58,7 +58,9 @@ export type ModalName = | 'mask' | 'log' | 'about' - | 'export' + | 'exportPng' + | 'exportClipboard' + | 'exportPixelium' | 'median' | 'dilate' | 'erode' @@ -97,7 +99,9 @@ export const initialViewState: ViewState = { mask: false, log: false, about: false, - export: false, + exportPng: false, + exportClipboard: false, + exportPixelium: false, median: false, dilate: false, erode: false,