Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@elysiajs/eden": "^1.2.0",
"@lingui/core": "^5.2.0",
"@lingui/react": "^5.2.0",
"@sinclair/typebox": "^0.34.28",
"@workspace/schema": "workspace:*",
"@workspace/ui": "workspace:*",
"browser-fs-access": "^0.35.0",
Expand Down
24 changes: 11 additions & 13 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,17 @@ export function App() {
<div className="bg-grid pointer-events-none absolute inset-0 [mask-image:linear-gradient(180deg,#fff_5%,transparent_80%)] select-none"></div>
<div className="relative grid h-dvh w-dvw grid-rows-[auto_1fr_auto] overflow-hidden p-5">
<header className="grid grid-cols-[1fr_2fr_1fr] items-center">
<section className="col-start-2 justify-self-center">
<div className="bg-card flex items-center rounded-md border p-1 shadow-xs sm:gap-1">
<ImportImages variant="ghost" />
<Separator orientation="vertical" className="h-4" />
<OptimizeWaveform />
<ChangeWaveFunction />
<ReverseImages variant="ghost" />
<Separator orientation="vertical" className="h-4" />
<CopyImage />
<ExportImage />
<Separator orientation="vertical" className="h-4" />
<RemoveImages variant="ghost" />
</div>
<section className="bg-card col-start-2 flex items-center justify-self-center rounded-md border p-1 shadow-xs sm:gap-1">
<ImportImages variant="ghost" />
<Separator orientation="vertical" className="h-4" />
<OptimizeWaveform />
<ChangeWaveFunction />
<ReverseImages variant="ghost" />
<Separator orientation="vertical" className="h-4" />
<CopyImage />
<ExportImage />
<Separator orientation="vertical" className="h-4" />
<RemoveImages variant="ghost" />
</section>
<ChangeTheme className="justify-self-end" variant="outline" />
</header>
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/atoms/areImagesReversedAtom.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { createJSONStorage } from 'jotai/utils';
import { Type } from '@sinclair/typebox';
import { atomWithToggleAndStorage } from '@/utils/atomWithToggleAndStorage';
import { withStorageValidator } from '@/utils/withStorageValidator';

export const areImagesReversedAtom = atomWithToggleAndStorage(
'areImagesReversed',
false,
undefined,
withStorageValidator(Type.Boolean())(createJSONStorage()),
{ getOnInit: true }
);
54 changes: 30 additions & 24 deletions apps/web/src/atoms/imagesAtom.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,39 @@
import { atom } from 'jotai';
import { atomWithStorage, RESET, unwrap } from 'jotai/utils';
import { readFile } from '@/utils/readFile';
import { atomWithStorage, unwrap } from 'jotai/utils';
import { Type } from '@sinclair/typebox';
import { readFileAsDataURL } from '@/utils/readFile';
import { loadImage } from '@/utils/loadImage';
import { createIndexedDbStorage } from '@/utils/createIndexedDbStorage';
import { createIdbKeyvalStorage } from '@/utils/createIdbKeyvalStorage';
import { withStorageValidator } from '@/utils/withStorageValidator';

let imageId = 0;
const ImageFileSchema = Type.Tuple([
Type.String({ title: 'filename' }),
Type.Unsafe<Blob>(Type.Any({ type: 'object' }))
]);

const filesAtom = atomWithStorage<Blob[]>(
'files',
[],
createIndexedDbStorage<Blob[]>(),
{ getOnInit: true }
);
const ImageFilesSchema = Type.Array(ImageFileSchema);

export type ImageFile = typeof ImageFileSchema.static;

export const imagesAtom = atom(
async get => {
const files = await get(filesAtom);
return Promise.all(
files.map(async file => {
const dataUrl = (await readFile(r => r.readAsDataURL(file))) as string;
const image = await loadImage(dataUrl);
image.id = `${imageId++}`;
return image;
})
);
},
(_get, set, files: Blob[] | typeof RESET) => {
set(filesAtom, files);
export const imageFilesAtom = atomWithStorage(
'images',
[],
withStorageValidator(ImageFilesSchema)(createIdbKeyvalStorage()),
{
getOnInit: true
}
);

export const imagesAtom = atom(async (get, options) => {
const imageFiles = await get(imageFilesAtom);
return Promise.all(
imageFiles.map(async ([id, file]) => {
const dataUrl = await readFileAsDataURL(file, options);
const image = await loadImage(dataUrl, options);
image.id = id;
return image;
})
);
});

export const unwrappedImagesAtom = unwrap(imagesAtom, prev => prev ?? []);
19 changes: 8 additions & 11 deletions apps/web/src/atoms/importAtoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import { atom } from 'jotai';
import { client } from '@/utils/client';
import { atomWithExpiringWriteState } from '@/utils/atomWithExpiringWriteState';
import { ImageSource } from '@/types';
import { largestImageAtom } from './largestImageAtom';
import { optimizeWaveformAtom } from './waveformAtoms';
import { imagesAtom } from './imagesAtom';
import { ImageFile, imageFilesAtom } from './imagesAtom';

const importAbortControllerAtom = atom<AbortController | null>(null);

Expand All @@ -23,15 +21,15 @@ export const importAtom = atomWithExpiringWriteState(
? imageSource
: [imageSource];

const blobs = (
await Promise.all<Blob[]>(
imageSources.map(async source => {
const imageFiles = (
await Promise.all(
imageSources.map<Promise<ImageFile[]>>(async source => {
if (source instanceof File) {
return [source];
return [[source.name, source]];
}
if (typeof source === 'string') {
const response = await fetch(source, { signal });
return [await response.blob()];
return [[source, await response.blob()]];
}
const { url, deviceType } = source;
const { data, error } = await client
Expand All @@ -44,12 +42,11 @@ export const importAtom = atomWithExpiringWriteState(
throw error.value;
}

return Object.values(data || {});
return Object.entries(data);
})
)
).flat();

set(imagesAtom, blobs);
set(optimizeWaveformAtom, await get(largestImageAtom));
set(imageFilesAtom, imageFiles);
}
);
4 changes: 2 additions & 2 deletions apps/web/src/atoms/waveAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ export const waveAtom = atom(get => {

for (let x = 0; x <= width; x++) {
const y =
amplitude * waveFunction((2 * Math.PI * x) / wavelength) + halfHeight;
-amplitude * waveFunction((2 * Math.PI * x) / wavelength) + halfHeight;
ys.push(round(y - prevY));
prevY = y;
}

return `m0 ${ys.join(' 1 ')} V0H0Z`;
return `m0 ${ys.join(' 1 ')} V${height}H0Z`;
});
10 changes: 8 additions & 2 deletions apps/web/src/atoms/waveFunctionAtom.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { atomWithStorage } from 'jotai/utils';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import { Type } from '@sinclair/typebox';
import { withStorageValidator } from '@/utils/withStorageValidator';
import { WAVE_FUNCTION } from '@/constants';
import { WaveFunction } from '@/types';

const WaveFunctionSchema = Type.Union(
Object.values(WAVE_FUNCTION).map(value => Type.Literal(value))
);

export const waveFunctionAtom = atomWithStorage<WaveFunction>(
'waveFunction',
WAVE_FUNCTION.HEARTBEAT,
undefined,
withStorageValidator(WaveFunctionSchema)(createJSONStorage()),
{ getOnInit: true }
);
71 changes: 49 additions & 22 deletions apps/web/src/atoms/waveformAtoms.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import { atom } from 'jotai';
import { observe } from 'jotai-effect';
import { focusAtom } from 'jotai-optics';
import { atomWithStorage } from 'jotai/utils';
import { atomWithStorage, createJSONStorage, RESET } from 'jotai/utils';
import { Type } from '@sinclair/typebox';
import { largestImageAtom } from '@/atoms/largestImageAtom';
import { withStorageValidator } from '@/utils/withStorageValidator';

export const MIN_ROTATION = 0;
export const MAX_ROTATION = 360;
export const MIN_WAVELENGTH = 9;
export const MIN_AMPLITUDE = 0;

export interface Waveform {
rotation: number;
wavelength: number;
wavelengthMax: number;
amplitude: number;
amplitudeMax: number;
}
const WaveformSchema = Type.Object({
rotation: Type.Number(),
wavelength: Type.Number(),
wavelengthMax: Type.Number(),
amplitude: Type.Number(),
amplitudeMax: Type.Number(),
_optimized: Type.Optional(Type.Boolean())
});

export type Waveform = typeof WaveformSchema.static;

export const waveformAtom = atomWithStorage<Waveform>(
'waveform',
Expand All @@ -24,10 +30,14 @@ export const waveformAtom = atomWithStorage<Waveform>(
wavelengthMax: MIN_WAVELENGTH + 1,
amplitudeMax: MIN_AMPLITUDE + 1
},
undefined,
withStorageValidator(WaveformSchema)(createJSONStorage()),
{ getOnInit: true }
);

export const isWaveformOptimizedAtom = focusAtom(waveformAtom, optic =>
optic.prop('_optimized')
);

// Rotation

export const rotationAtom = focusAtom(waveformAtom, optic =>
Expand All @@ -54,20 +64,37 @@ export const amplitudeMaxAtom = focusAtom(waveformAtom, optic =>
optic.prop('amplitudeMax')
);

export const optimizeWaveformAtom = atom(
null,
(_get, set, image?: HTMLImageElement) => {
const wavelengthMax = image?.width ?? MIN_WAVELENGTH + 1;
const amplitudeMax = image
? Math.round(image.height / 8)
: MIN_AMPLITUDE + 1;
observe((get, set) => {
const abortController = new AbortController();
const largestImagePromise = get(largestImageAtom);
const isOptimized = get(isWaveformOptimizedAtom);

set(waveformAtom, {
(async () => {
const largestImage = await largestImagePromise;
if (abortController.signal.aborted) {
return;
}
if (!largestImage) {
set(waveformAtom, RESET);
return;
}
if (isOptimized) {
return;
}
const { width, height } = largestImage;
const wavelengthMax = width;
const amplitudeMax = Math.round(height / 8);

set(waveformAtom, prev => ({
...prev,
rotation: 0,
wavelength: wavelengthMax,
amplitude: amplitudeMax,
wavelengthMax,
amplitudeMax
});
}
);
amplitudeMax,
_optimized: true
}));
})();

return () => abortController.abort();
});
8 changes: 4 additions & 4 deletions apps/web/src/components/ChangeWaveFunction.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { useAtom, useAtomValue } from 'jotai';
import { useLingui } from '@lingui/react/macro';
import { waveFunctionAtom } from '@/atoms/waveFunctionAtom';
import {
ToggleGroup,
ToggleGroupItem
} from '@workspace/ui/components/toggle-group';
import { Sin } from '@/components/Sin';
import { Heartbeat } from '@/components/Heartbeat';
import { unwrappedImagesAtom } from '@/atoms/imagesAtom';
import { Sin } from '@/icons/Sin';
import { Heartbeat } from '@/icons/Heartbeat';
import { WAVE_FUNCTION } from '@/constants';
import { WaveFunction } from '@/types';
import { unwrappedImagesAtom } from '@/atoms/imagesAtom';
import { useLingui } from '@lingui/react/macro';

export type ChanveWaveFunctionProps = Omit<
React.ComponentProps<typeof ToggleGroup>,
Expand Down
19 changes: 7 additions & 12 deletions apps/web/src/components/Graphics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,19 @@ export function Graphics(props: GraphicsProps) {
<svg
ref={setGraphics}
viewBox={`0 0 ${width} ${height}`}
className={cn(
'h-full w-full',
'transform-gpu will-change-transform',
'max-h-(--Graphics-height) max-w-(--Graphics-width)',
svgClassName
)}
preserveAspectRatio=""
style={
{
'--Graphics-width': `${width}px`,
'--Graphics-height': `${height}px`,
...svgStyle
} as React.CSSProperties
}
className={cn(
'h-full w-full',
'transform-gpu will-change-transform',
'max-h-(--Graphics-height) max-w-(--Graphics-width)',
svgClassName
)}
{...svgOther}
>
<defs>
Expand All @@ -94,11 +93,7 @@ export function Graphics(props: GraphicsProps) {
<image
key={image.id}
href={image.src}
clipPath={
i >= 1
? `url(#${waveId}${orderedImages.length - i})`
: undefined
}
clipPath={i >= 1 ? `url(#${waveId}${i})` : undefined}
/>
))}
</g>
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/OptimizeWaveform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ import { WandSparkles } from 'lucide-react';
import { useLingui } from '@lingui/react/macro';
import { Button } from '@workspace/ui/components/button';
import { Tooltip } from '@workspace/ui/components/tooltip';
import { optimizeWaveformAtom } from '@/atoms/waveformAtoms';
import { isWaveformOptimizedAtom } from '@/atoms/waveformAtoms';
import { unwrappedLargestImageAtom } from '@/atoms/largestImageAtom';

export function OptimizeWaveform() {
const { t } = useLingui();
const largestImage = useAtomValue(unwrappedLargestImageAtom);
const optimizeWaveform = useSetAtom(optimizeWaveformAtom);
const setWaveformOptimized = useSetAtom(isWaveformOptimizedAtom);

const handleClick = () => {
optimizeWaveform(largestImage);
setWaveformOptimized(false);
};

return (
Expand Down
Loading