Skip to content

Commit

Permalink
export dialog
Browse files Browse the repository at this point in the history
  • Loading branch information
loganzartman committed Mar 17, 2024
1 parent 0986a9c commit 96c7a87
Show file tree
Hide file tree
Showing 4 changed files with 239 additions and 14 deletions.
16 changes: 13 additions & 3 deletions src/app/reacjin/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
import clsx from 'clsx';
import * as React from 'react';

type ButtonProps = React.HTMLAttributes<HTMLButtonElement> & {
children?: React.ReactNode;
icon?: React.ReactNode;
size?: 'md' | 'lg';
};

const sizeClasses = {
md: 'text-md py-1 px-2',
lg: 'text-lg py-2 px-3',
};

export const Button = React.forwardRef(
(
{children, icon, ...attrs}: ButtonProps,
{children, icon, size = 'md', ...attrs}: ButtonProps,
ref: React.Ref<HTMLButtonElement>,
) => (
<button
ref={ref}
className="py-1 px-2 flex flex-row items-center gap-1 ring-2 ring-brand-200/50 rounded-md transition-colors hover:ring-brand-400 hover:bg-brand-400 hover:text-background"
className={clsx(
'py-1 px-2 flex flex-row items-center justify-center gap-1 ring-2 ring-brand-200/50 rounded-md transition-colors hover:ring-brand-400 hover:bg-brand-400 hover:text-background',
sizeClasses[size],
)}
{...attrs}
>
{icon && <div>{icon}</div>}
<div>{children}</div>
{children && <div>{children}</div>}
</button>
),
);
Expand Down
67 changes: 67 additions & 0 deletions src/app/reacjin/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {Dialog as HeadlessDialog, Transition} from '@headlessui/react';
import React, {Fragment} from 'react';
import {MdOutlineClose} from 'react-icons/md';

import {Button} from '@/app/reacjin/Button';

export function Dialog({
isOpen,
handleClose,
children,
title,
showCloseButton,
}: {
isOpen: boolean;
handleClose: () => void;
children: React.ReactNode;
title: React.ReactNode;
showCloseButton?: boolean;
}) {
const closeButton = showCloseButton && (
<Button icon={<MdOutlineClose />} onClick={handleClose} />
);
return (
<Transition appear show={isOpen} as={Fragment}>
<HeadlessDialog as="div" className="relative z-10" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-background bg-opacity-25 backdrop-blur-sm" />
</Transition.Child>

<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 -translate-y-2"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0 -translate-y-2"
>
<HeadlessDialog.Panel className="w-full max-w-md transform overflow-hidden rounded-lg ring-2 ring-brand-100/20 bg-background shadow-black shadow-lg p-4 pb-6 text-left align-middle transition-all">
<HeadlessDialog.Title
as="div"
className="flex flex-row justify-between items-center text-xl font-semibold text-fg-300"
>
<h3>{title}</h3>
<div>{closeButton}</div>
</HeadlessDialog.Title>
<div className="text-md font-medium text-fg-200 mt-6">
{children}
</div>
</HeadlessDialog.Panel>
</Transition.Child>
</div>
</div>
</HeadlessDialog>
</Transition>
);
}
150 changes: 150 additions & 0 deletions src/app/reacjin/ExportDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {useCallback, useMemo, useState} from 'react';
import {MdOutlineDownload} from 'react-icons/md';

import {Button} from '@/app/reacjin/Button';
import {ComboRange} from '@/app/reacjin/ComboRange';
import {Dialog} from '@/app/reacjin/Dialog';

const exportTypes = ['webp', 'png', 'jpeg'];

const getSupportedExportTypes = (() => {
let canvas: HTMLCanvasElement | undefined;
return (allTypes: string[]) => {
if (!canvas) canvas = document.createElement('canvas');
return allTypes.filter((type) =>
canvas!.toDataURL(`image/${type}`).startsWith(`data:image/${type}`),
);
};
})();

function estimateFileSizeB(dataURL: string) {
const {b64} =
dataURL.match(/data:image\/[^;]+;base64,(?<b64>.+)/)?.groups ?? {};
if (!b64) throw new Error('Invalid data URL');
const binary = atob(b64);
return binary.length;
}

const bytesFormat = new Intl.NumberFormat(navigator.language, {
maximumFractionDigits: 2,
});
function formatBytes(bytes: number): string {
if (bytes === 0) return '0B';

const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));

return `${bytesFormat.format(bytes / Math.pow(k, i))} ${sizes[i]}`;
}

function generateDataURL(
canvas: HTMLCanvasElement | undefined | null,
exportType: string,
quality: number,
) {
if (!canvas) return null;
return canvas.toDataURL(`image/${exportType}`, quality);
}

export function ExportDialog({
isOpen,
handleClose,
canvas,
filename,
}: {
isOpen: boolean;
handleClose: () => void;
canvas: HTMLCanvasElement | undefined | null;
filename: string;
}) {
const [exportFilename, setExportFilename] = useState(filename);
const [quality, setQuality] = useState(1);
const [exportType, setExportType] = useState(exportTypes[0]);
const dataURL = generateDataURL(canvas, exportType, quality);

const supportedExportTypes = useMemo(
() => getSupportedExportTypes(exportTypes),
[],
);

const fileSize = useMemo(() => {
if (!dataURL) return null;
return formatBytes(estimateFileSizeB(dataURL));
}, [dataURL]);

const handleDownload = useCallback(() => {
if (!dataURL) return;
const a = document.createElement('a');
a.href = dataURL;
a.download = `${exportFilename}.${exportType}`;
a.click();
handleClose();
}, [dataURL, filename, handleClose]);

return (
<Dialog
isOpen={isOpen}
handleClose={handleClose}
title="Export"
showCloseButton
>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<label className="form-control">
Filename
<div className="flex flex-row items-center gap-2">
<input
type="text"
value={exportFilename}
onChange={(e) => setExportFilename(e.target.value)}
className="input input-bordered input-sm grow"
/>
<select
id="exportType"
value={exportType}
onChange={(e) => setExportType(e.target.value)}
className="select select-bordered select-sm shrink"
>
{supportedExportTypes.map((type) => (
<option key={type} value={type}>
.{type}
</option>
))}
</select>
</div>
</label>
<label className="form-control">
Quality
<ComboRange
value={quality}
onChange={setQuality}
min={0}
max={1}
step={0.01}
/>
</label>
<div>
<div>Preview</div>
{dataURL && (
<img
src={dataURL}
alt="Preview"
className="mx-auto select-none h-[256px]"
onDragStart={(e) => e.preventDefault()}
/>
)}
<div>Size: {fileSize}</div>
</div>
<Button
size="lg"
icon={<MdOutlineDownload />}
onClick={handleDownload}
>
Download
</Button>
</div>
</div>
</Dialog>
);
}
20 changes: 9 additions & 11 deletions src/app/reacjin/ReacjinEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {useLocalStorage} from 'usehooks-ts';
import {Button} from '@/app/reacjin/Button';
import {ComboRange} from '@/app/reacjin/ComboRange';
import {ComputedCache} from '@/app/reacjin/ComputedCache';
import {ExportDialog} from '@/app/reacjin/ExportDialog';
import {FAB} from '@/app/reacjin/FAB';
import {ImageCanvas} from '@/app/reacjin/ImageCanvas';
import {
Expand Down Expand Up @@ -63,6 +64,7 @@ export default function ReacjinEditor() {
const [computedCache] = useState(() => new ComputedCache());
const [computing, setComputing] = useState(false);
const [dropping, setDropping] = useState(false);
const [showExportDialog, setShowExportDialog] = useState(false);

useEffect(() => {
function handler(event: KeyboardEvent) {
Expand Down Expand Up @@ -160,16 +162,6 @@ export default function ReacjinEditor() {
[setLayers],
);

const handleDownloadImage = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const url = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = url;
a.download = 'reacji.png';
a.click();
}, []);

const handleAddImage = useCallback(() => {
setLayers((layers) => [createImageLayer({}), ...layers]);
}, [setLayers]);
Expand Down Expand Up @@ -294,7 +286,7 @@ export default function ReacjinEditor() {
</div>
</div>
</div>
<FAB onClick={handleDownloadImage}>
<FAB onClick={() => setShowExportDialog(true)}>
<MdOutlineFileDownload />
</FAB>
<AnimatePresence>
Expand All @@ -314,6 +306,12 @@ export default function ReacjinEditor() {
</MotionDiv>
)}
</AnimatePresence>
<ExportDialog
isOpen={showExportDialog}
handleClose={() => setShowExportDialog(false)}
canvas={canvasRef.current}
filename="reacji"
/>
</div>
</PanelProvider>
);
Expand Down

0 comments on commit 96c7a87

Please sign in to comment.