Port frontend from Vue to Svelte (WIP in separate folder, many bugs) (#964)

* Make `tauri build` just work

* Move folder: frontend/wasm -> wasm

* Create SvelteKit project with 'npx create-svelte'

* Move wasm-communication into seperate npm package

* Use wasm-pack directly and pack package.json

* Got it to work

* Add primitive build script for wasm npm module

* Fix wasm build script (python)

* Clean up glue code

* Rewrite wasm build script in node.js

* Add serde-reflection to trace types

* Add traced types

* Generate typescript (.d.ts) from Rust types

* Update .d.ts

* Finalize TS types

* Add script to update .d.ts

* Add watch command to build wasm-bindgen

* Make wasm work again

* Add sass; fix build script for windows

* Describe requirement for wasm-pack

* Add license

* Copy and reorganize vue components

* translate LayoutCol.vue

* Split app.scss into pieces

* Translate LayoutRow.svelte

* Rename scss files

* Fix compile issues on Windows

* WIP port TitleBar

* Support classes for LayoutCol/Row

* Restructure based on Vue codebase

* Port all components in window folder

* Port FloatingMenu

* Port Document panel component

* Update readme after folder move

* Update typegen: print discriminant by default

* Update typegen: Merge from branch 'tailwind' 4f14fedb

Fixes bigint & bytes

* Made Vue/webpack/eslint to accept wasm package at new location

This is quite a hack.

Those two packages are both named the same. Yes, it's an npm package inside another npm package.

- frontend/src/wasm-communication/
- frontend-svelte/glue/

'wasm/pkg/index.js' imports the correct one registered when linking.

* Port LayerTree

* Port NodeGraph

* Port Properties

* Port components in /floating-menus

* Finish porting all Vue -> Svelte components

* Change import prefix

* Revert type generation

* Revert moved wasm folder

* Revert all of @locriacyber's work on this branch

- Remove Vite and restore Webpack
- Remove SvelteKit
- Remove everything except the components I ported to Svelte
- Restore all frontend files from Vue code, now altered for Svelte

* Convert Vue's 'reactive' and 'provide' to Svelte's stores and contexts

* Fix event emitting and bi-di data flow

* Undo removal of 'update:' events

* Fix 'update:' event dispatching

* Fix usage in parent of bi-di component props

* Fix component typing, more progress towards no errors

* The page builds and opens!

* Add loading spinner and remove postcss dependency

* Make the basics of document editing work

* Fix rebase history

Co-authored-by: Locria Cyber <74560659+locriacyber@users.noreply.github.com>
This commit is contained in:
Keavon Chambers
2023-01-13 01:05:20 -08:00
co-authored by Locria Cyber
parent 01853fe4b7
commit 5619d44300
239 changed files with 27757 additions and 13 deletions
@@ -0,0 +1,31 @@
export type Debouncer = ReturnType<typeof debouncer>;
export type DebouncerOptions = {
debounceTime: number;
};
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function debouncer<T>(callFn: (value: T) => unknown, { debounceTime = 60 }: Partial<DebouncerOptions> = {}) {
let currentValue: T | undefined;
const emitValue = (): void => {
if (currentValue === undefined) {
throw new Error("Tried to emit undefined value from debouncer. This should never be possible");
}
const emittingValue = currentValue;
currentValue = undefined;
callFn(emittingValue);
};
const updateValue = (newValue: T): void => {
if (currentValue !== undefined) {
currentValue = newValue;
return;
}
currentValue = newValue;
setTimeout(emitValue, debounceTime);
};
return { updateValue };
}
@@ -0,0 +1,14 @@
/* eslint-disable no-useless-escape */
/* eslint-disable quotes */
export function escapeJSON(str: string): string {
return str
.replace(/[\\]/g, "\\\\")
.replace(/[\"]/g, '\\"')
.replace(/[\/]/g, "\\/")
.replace(/[\b]/g, "\\b")
.replace(/[\f]/g, "\\f")
.replace(/[\n]/g, "\\n")
.replace(/[\r]/g, "\\r")
.replace(/[\t]/g, "\\t");
}
@@ -0,0 +1,82 @@
export function downloadFileURL(filename: string, url: string): void {
const element = document.createElement("a");
element.href = url;
element.setAttribute("download", filename);
element.click();
}
export function downloadFileBlob(filename: string, blob: Blob): void {
const url = URL.createObjectURL(blob);
downloadFileURL(filename, url);
URL.revokeObjectURL(url);
}
export function downloadFileText(filename: string, text: string): void {
const type = filename.endsWith(".svg") ? "image/svg+xml;charset=utf-8" : "text/plain;charset=utf-8";
const blob = new Blob([text], { type });
downloadFileBlob(filename, blob);
}
export async function upload<T extends "text" | "data">(acceptedExtensions: string, textOrData: T): Promise<UploadResult<T>> {
return new Promise<UploadResult<T>>((resolve, _) => {
const element = document.createElement("input");
element.type = "file";
element.accept = acceptedExtensions;
element.addEventListener(
"change",
async () => {
if (element.files?.length) {
const file = element.files[0];
const filename = file.name;
const type = file.type;
const content = (textOrData === "text" ? await file.text() : new Uint8Array(await file.arrayBuffer())) as UploadResultType<T>;
resolve({ filename, type, content });
}
},
{ capture: false, once: true }
);
element.click();
// Once `element` goes out of scope, it has no references so it gets garbage collected along with its event listener, so `removeEventListener` is not needed
});
}
export type UploadResult<T> = { filename: string; type: string; content: UploadResultType<T> };
type UploadResultType<T> = T extends "text" ? string : T extends "data" ? Uint8Array : never;
export function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = (): void => resolve(typeof reader.result === "string" ? reader.result : "");
reader.readAsDataURL(blob);
});
}
export async function replaceBlobURLsWithBase64(svg: string): Promise<string> {
const splitByBlobs = svg.split(/("blob:.*?")/);
const onlyBlobs = splitByBlobs.filter((_, i) => i % 2 === 1);
const onlyBlobsConverted = onlyBlobs.map(async (blobURL) => {
const urlWithoutQuotes = blobURL.slice(1, -1);
const data = await fetch(urlWithoutQuotes);
const dataBlob = await data.blob();
return blobToBase64(dataBlob);
});
const base64Images = await Promise.all(onlyBlobsConverted);
const substituted = splitByBlobs.map((segment, i) => {
if (i % 2 === 0) return segment;
const blobsIndex = Math.floor(i / 2);
return `"${base64Images[blobsIndex]}"`;
});
return substituted.join("");
}
@@ -0,0 +1,300 @@
/* eslint-disable import/first */
// Graphics
import GraphiteLogotypeSolid from "@/../assets/graphics/graphite-logotype-solid.svg";
const GRAPHICS = {
GraphiteLogotypeSolid: { svg: GraphiteLogotypeSolid, size: undefined },
} as const;
// 12px Solid
import Add from "@/../assets/icon-12px-solid/add.svg";
import Checkmark from "@/../assets/icon-12px-solid/checkmark.svg";
import CloseX from "@/../assets/icon-12px-solid/close-x.svg";
import DropdownArrow from "@/../assets/icon-12px-solid/dropdown-arrow.svg";
import Edit from "@/../assets/icon-12px-solid/edit.svg";
import Empty12px from "@/../assets/icon-12px-solid/empty-12px.svg";
import FullscreenEnter from "@/../assets/icon-12px-solid/fullscreen-enter.svg";
import FullscreenExit from "@/../assets/icon-12px-solid/fullscreen-exit.svg";
import Grid from "@/../assets/icon-12px-solid/grid.svg";
import Info from "@/../assets/icon-12px-solid/info.svg";
import KeyboardArrowDown from "@/../assets/icon-12px-solid/keyboard-arrow-down.svg";
import KeyboardArrowLeft from "@/../assets/icon-12px-solid/keyboard-arrow-left.svg";
import KeyboardArrowRight from "@/../assets/icon-12px-solid/keyboard-arrow-right.svg";
import KeyboardArrowUp from "@/../assets/icon-12px-solid/keyboard-arrow-up.svg";
import KeyboardBackspace from "@/../assets/icon-12px-solid/keyboard-backspace.svg";
import KeyboardCommand from "@/../assets/icon-12px-solid/keyboard-command.svg";
import KeyboardControl from "@/../assets/icon-12px-solid/keyboard-control.svg";
import KeyboardEnter from "@/../assets/icon-12px-solid/keyboard-enter.svg";
import KeyboardOption from "@/../assets/icon-12px-solid/keyboard-option.svg";
import KeyboardShift from "@/../assets/icon-12px-solid/keyboard-shift.svg";
import KeyboardSpace from "@/../assets/icon-12px-solid/keyboard-space.svg";
import KeyboardTab from "@/../assets/icon-12px-solid/keyboard-tab.svg";
import Link from "@/../assets/icon-12px-solid/link.svg";
import Overlays from "@/../assets/icon-12px-solid/overlays.svg";
import Remove from "@/../assets/icon-12px-solid/remove.svg";
import ResetColors from "@/../assets/icon-12px-solid/reset-colors.svg";
import Snapping from "@/../assets/icon-12px-solid/snapping.svg";
import Swap from "@/../assets/icon-12px-solid/swap.svg";
import VerticalEllipsis from "@/../assets/icon-12px-solid/vertical-ellipsis.svg";
import Warning from "@/../assets/icon-12px-solid/warning.svg";
import WindowButtonWinClose from "@/../assets/icon-12px-solid/window-button-win-close.svg";
import WindowButtonWinMaximize from "@/../assets/icon-12px-solid/window-button-win-maximize.svg";
import WindowButtonWinMinimize from "@/../assets/icon-12px-solid/window-button-win-minimize.svg";
import WindowButtonWinRestoreDown from "@/../assets/icon-12px-solid/window-button-win-restore-down.svg";
const SOLID_12PX = {
Add: { svg: Add, size: 12 },
Checkmark: { svg: Checkmark, size: 12 },
CloseX: { svg: CloseX, size: 12 },
DropdownArrow: { svg: DropdownArrow, size: 12 },
Edit: { svg: Edit, size: 12 },
Empty12px: { svg: Empty12px, size: 12 },
FullscreenEnter: { svg: FullscreenEnter, size: 12 },
FullscreenExit: { svg: FullscreenExit, size: 12 },
Grid: { svg: Grid, size: 12 },
Info: { svg: Info, size: 12 },
KeyboardArrowDown: { svg: KeyboardArrowDown, size: 12 },
KeyboardArrowLeft: { svg: KeyboardArrowLeft, size: 12 },
KeyboardArrowRight: { svg: KeyboardArrowRight, size: 12 },
KeyboardArrowUp: { svg: KeyboardArrowUp, size: 12 },
KeyboardBackspace: { svg: KeyboardBackspace, size: 12 },
KeyboardCommand: { svg: KeyboardCommand, size: 12 },
KeyboardControl: { svg: KeyboardControl, size: 12 },
KeyboardEnter: { svg: KeyboardEnter, size: 12 },
KeyboardOption: { svg: KeyboardOption, size: 12 },
KeyboardShift: { svg: KeyboardShift, size: 12 },
KeyboardSpace: { svg: KeyboardSpace, size: 12 },
KeyboardTab: { svg: KeyboardTab, size: 12 },
Link: { svg: Link, size: 12 },
Overlays: { svg: Overlays, size: 12 },
Remove: { svg: Remove, size: 12 },
ResetColors: { svg: ResetColors, size: 12 },
Snapping: { svg: Snapping, size: 12 },
Swap: { svg: Swap, size: 12 },
VerticalEllipsis: { svg: VerticalEllipsis, size: 12 },
Warning: { svg: Warning, size: 12 },
WindowButtonWinClose: { svg: WindowButtonWinClose, size: 12 },
WindowButtonWinMaximize: { svg: WindowButtonWinMaximize, size: 12 },
WindowButtonWinMinimize: { svg: WindowButtonWinMinimize, size: 12 },
WindowButtonWinRestoreDown: { svg: WindowButtonWinRestoreDown, size: 12 },
} as const;
// 16px Solid
import AlignBottom from "@/../assets/icon-16px-solid/align-bottom.svg";
import AlignHorizontalCenter from "@/../assets/icon-16px-solid/align-horizontal-center.svg";
import AlignLeft from "@/../assets/icon-16px-solid/align-left.svg";
import AlignRight from "@/../assets/icon-16px-solid/align-right.svg";
import AlignTop from "@/../assets/icon-16px-solid/align-top.svg";
import AlignVerticalCenter from "@/../assets/icon-16px-solid/align-vertical-center.svg";
import BooleanDifference from "@/../assets/icon-16px-solid/boolean-difference.svg";
import BooleanIntersect from "@/../assets/icon-16px-solid/boolean-intersect.svg";
import BooleanSubtractBack from "@/../assets/icon-16px-solid/boolean-subtract-back.svg";
import BooleanSubtractFront from "@/../assets/icon-16px-solid/boolean-subtract-front.svg";
import BooleanUnion from "@/../assets/icon-16px-solid/boolean-union.svg";
import CheckboxChecked from "@/../assets/icon-16px-solid/checkbox-checked.svg";
import CheckboxUnchecked from "@/../assets/icon-16px-solid/checkbox-unchecked.svg";
import Copy from "@/../assets/icon-16px-solid/copy.svg";
import EyeHidden from "@/../assets/icon-16px-solid/eye-hidden.svg";
import EyeVisible from "@/../assets/icon-16px-solid/eye-visible.svg";
import Eyedropper from "@/../assets/icon-16px-solid/eyedropper.svg";
import File from "@/../assets/icon-16px-solid/file.svg";
import FlipHorizontal from "@/../assets/icon-16px-solid/flip-horizontal.svg";
import FlipVertical from "@/../assets/icon-16px-solid/flip-vertical.svg";
import Folder from "@/../assets/icon-16px-solid/folder.svg";
import GraphiteLogo from "@/../assets/icon-16px-solid/graphite-logo.svg";
import NodeArtboard from "@/../assets/icon-16px-solid/node-artboard.svg";
import NodeBlur from "@/../assets/icon-16px-solid/node-blur.svg";
import NodeBrushwork from "@/../assets/icon-16px-solid/node-brushwork.svg";
import NodeColorCorrection from "@/../assets/icon-16px-solid/node-color-correction.svg";
import NodeFolder from "@/../assets/icon-16px-solid/node-folder.svg";
import NodeGradient from "@/../assets/icon-16px-solid/node-gradient.svg";
import NodeImage from "@/../assets/icon-16px-solid/node-image.svg";
import NodeImaginate from "@/../assets/icon-16px-solid/node-imaginate.svg";
import NodeMagicWand from "@/../assets/icon-16px-solid/node-magic-wand.svg";
import NodeMask from "@/../assets/icon-16px-solid/node-mask.svg";
import NodeMotionBlur from "@/../assets/icon-16px-solid/node-motion-blur.svg";
import NodeNodes from "@/../assets/icon-16px-solid/node-nodes.svg";
import NodeOutput from "@/../assets/icon-16px-solid/node-output.svg";
import NodeShape from "@/../assets/icon-16px-solid/node-shape.svg";
import NodeText from "@/../assets/icon-16px-solid/node-text.svg";
import NodeTransform from "@/../assets/icon-16px-solid/node-transform.svg";
import Paste from "@/../assets/icon-16px-solid/paste.svg";
import Random from "@/../assets/icon-16px-solid/random.svg";
import Regenerate from "@/../assets/icon-16px-solid/regenerate.svg";
import Reload from "@/../assets/icon-16px-solid/reload.svg";
import Rescale from "@/../assets/icon-16px-solid/rescale.svg";
import Reset from "@/../assets/icon-16px-solid/reset.svg";
import Settings from "@/../assets/icon-16px-solid/settings.svg";
import Trash from "@/../assets/icon-16px-solid/trash.svg";
import ViewModeNormal from "@/../assets/icon-16px-solid/view-mode-normal.svg";
import ViewModeOutline from "@/../assets/icon-16px-solid/view-mode-outline.svg";
import ViewModePixels from "@/../assets/icon-16px-solid/view-mode-pixels.svg";
import ViewportDesignMode from "@/../assets/icon-16px-solid/viewport-design-mode.svg";
import ViewportGuideMode from "@/../assets/icon-16px-solid/viewport-guide-mode.svg";
import ViewportSelectMode from "@/../assets/icon-16px-solid/viewport-select-mode.svg";
import ZoomIn from "@/../assets/icon-16px-solid/zoom-in.svg";
import ZoomOut from "@/../assets/icon-16px-solid/zoom-out.svg";
import ZoomReset from "@/../assets/icon-16px-solid/zoom-reset.svg";
const SOLID_16PX = {
AlignBottom: { svg: AlignBottom, size: 16 },
AlignHorizontalCenter: { svg: AlignHorizontalCenter, size: 16 },
AlignLeft: { svg: AlignLeft, size: 16 },
AlignRight: { svg: AlignRight, size: 16 },
AlignTop: { svg: AlignTop, size: 16 },
AlignVerticalCenter: { svg: AlignVerticalCenter, size: 16 },
BooleanDifference: { svg: BooleanDifference, size: 16 },
BooleanIntersect: { svg: BooleanIntersect, size: 16 },
BooleanSubtractBack: { svg: BooleanSubtractBack, size: 16 },
BooleanSubtractFront: { svg: BooleanSubtractFront, size: 16 },
BooleanUnion: { svg: BooleanUnion, size: 16 },
CheckboxChecked: { svg: CheckboxChecked, size: 16 },
CheckboxUnchecked: { svg: CheckboxUnchecked, size: 16 },
Copy: { svg: Copy, size: 16 },
Eyedropper: { svg: Eyedropper, size: 16 },
EyeHidden: { svg: EyeHidden, size: 16 },
EyeVisible: { svg: EyeVisible, size: 16 },
File: { svg: File, size: 16 },
FlipHorizontal: { svg: FlipHorizontal, size: 16 },
FlipVertical: { svg: FlipVertical, size: 16 },
Folder: { svg: Folder, size: 16 },
GraphiteLogo: { svg: GraphiteLogo, size: 16 },
NodeArtboard: { svg: NodeArtboard, size: 16 },
NodeBlur: { svg: NodeBlur, size: 16 },
NodeBrushwork: { svg: NodeBrushwork, size: 16 },
NodeColorCorrection: { svg: NodeColorCorrection, size: 16 },
NodeFolder: { svg: NodeFolder, size: 16 },
NodeGradient: { svg: NodeGradient, size: 16 },
NodeImage: { svg: NodeImage, size: 16 },
NodeImaginate: { svg: NodeImaginate, size: 16 },
NodeMagicWand: { svg: NodeMagicWand, size: 16 },
NodeMask: { svg: NodeMask, size: 16 },
NodeMotionBlur: { svg: NodeMotionBlur, size: 16 },
NodeNodes: { svg: NodeNodes, size: 16 },
NodeOutput: { svg: NodeOutput, size: 16 },
NodeShape: { svg: NodeShape, size: 16 },
NodeText: { svg: NodeText, size: 16 },
NodeTransform: { svg: NodeTransform, size: 16 },
Paste: { svg: Paste, size: 16 },
Random: { svg: Random, size: 16 },
Regenerate: { svg: Regenerate, size: 16 },
Reload: { svg: Reload, size: 16 },
Rescale: { svg: Rescale, size: 16 },
Reset: { svg: Reset, size: 16 },
Settings: { svg: Settings, size: 16 },
Trash: { svg: Trash, size: 16 },
ViewModeNormal: { svg: ViewModeNormal, size: 16 },
ViewModeOutline: { svg: ViewModeOutline, size: 16 },
ViewModePixels: { svg: ViewModePixels, size: 16 },
ViewportDesignMode: { svg: ViewportDesignMode, size: 16 },
ViewportGuideMode: { svg: ViewportGuideMode, size: 16 },
ViewportSelectMode: { svg: ViewportSelectMode, size: 16 },
ZoomIn: { svg: ZoomIn, size: 16 },
ZoomOut: { svg: ZoomOut, size: 16 },
ZoomReset: { svg: ZoomReset, size: 16 },
} as const;
// 16px Two-Tone
import MouseHintDrag from "@/../assets/icon-16px-two-tone/mouse-hint-drag.svg";
import MouseHintLmbDrag from "@/../assets/icon-16px-two-tone/mouse-hint-lmb-drag.svg";
import MouseHintLmb from "@/../assets/icon-16px-two-tone/mouse-hint-lmb.svg";
import MouseHintMmbDrag from "@/../assets/icon-16px-two-tone/mouse-hint-mmb-drag.svg";
import MouseHintMmb from "@/../assets/icon-16px-two-tone/mouse-hint-mmb.svg";
import MouseHintNone from "@/../assets/icon-16px-two-tone/mouse-hint-none.svg";
import MouseHintRmbDrag from "@/../assets/icon-16px-two-tone/mouse-hint-rmb-drag.svg";
import MouseHintRmb from "@/../assets/icon-16px-two-tone/mouse-hint-rmb.svg";
import MouseHintScrollDown from "@/../assets/icon-16px-two-tone/mouse-hint-scroll-down.svg";
import MouseHintScrollUp from "@/../assets/icon-16px-two-tone/mouse-hint-scroll-up.svg";
const TWO_TONE_16PX = {
MouseHintDrag: { svg: MouseHintDrag, size: 16 },
MouseHintLmb: { svg: MouseHintLmb, size: 16 },
MouseHintLmbDrag: { svg: MouseHintLmbDrag, size: 16 },
MouseHintMmb: { svg: MouseHintMmb, size: 16 },
MouseHintMmbDrag: { svg: MouseHintMmbDrag, size: 16 },
MouseHintNone: { svg: MouseHintNone, size: 16 },
MouseHintRmb: { svg: MouseHintRmb, size: 16 },
MouseHintRmbDrag: { svg: MouseHintRmbDrag, size: 16 },
MouseHintScrollDown: { svg: MouseHintScrollDown, size: 16 },
MouseHintScrollUp: { svg: MouseHintScrollUp, size: 16 },
} as const;
// 24px Two-Tone
import GeneralArtboardTool from "@/../assets/icon-24px-two-tone/general-artboard-tool.svg";
import GeneralEyedropperTool from "@/../assets/icon-24px-two-tone/general-eyedropper-tool.svg";
import GeneralFillTool from "@/../assets/icon-24px-two-tone/general-fill-tool.svg";
import GeneralGradientTool from "@/../assets/icon-24px-two-tone/general-gradient-tool.svg";
import GeneralNavigateTool from "@/../assets/icon-24px-two-tone/general-navigate-tool.svg";
import GeneralSelectTool from "@/../assets/icon-24px-two-tone/general-select-tool.svg";
import RasterBrushTool from "@/../assets/icon-24px-two-tone/raster-brush-tool.svg";
import RasterCloneTool from "@/../assets/icon-24px-two-tone/raster-clone-tool.svg";
import RasterDetailTool from "@/../assets/icon-24px-two-tone/raster-detail-tool.svg";
import RasterHealTool from "@/../assets/icon-24px-two-tone/raster-heal-tool.svg";
import RasterImaginateTool from "@/../assets/icon-24px-two-tone/raster-imaginate-tool.svg";
import RasterNodesTool from "@/../assets/icon-24px-two-tone/raster-nodes-tool.svg";
import RasterPatchTool from "@/../assets/icon-24px-two-tone/raster-patch-tool.svg";
import RasterRelightTool from "@/../assets/icon-24px-two-tone/raster-relight-tool.svg";
import VectorEllipseTool from "@/../assets/icon-24px-two-tone/vector-ellipse-tool.svg";
import VectorFreehandTool from "@/../assets/icon-24px-two-tone/vector-freehand-tool.svg";
import VectorLineTool from "@/../assets/icon-24px-two-tone/vector-line-tool.svg";
import VectorPathTool from "@/../assets/icon-24px-two-tone/vector-path-tool.svg";
import VectorPenTool from "@/../assets/icon-24px-two-tone/vector-pen-tool.svg";
import VectorRectangleTool from "@/../assets/icon-24px-two-tone/vector-rectangle-tool.svg";
import VectorShapeTool from "@/../assets/icon-24px-two-tone/vector-shape-tool.svg";
import VectorSplineTool from "@/../assets/icon-24px-two-tone/vector-spline-tool.svg";
import VectorTextTool from "@/../assets/icon-24px-two-tone/vector-text-tool.svg";
const TWO_TONE_24PX = {
GeneralArtboardTool: { svg: GeneralArtboardTool, size: 24 },
GeneralEyedropperTool: { svg: GeneralEyedropperTool, size: 24 },
GeneralFillTool: { svg: GeneralFillTool, size: 24 },
GeneralGradientTool: { svg: GeneralGradientTool, size: 24 },
GeneralNavigateTool: { svg: GeneralNavigateTool, size: 24 },
GeneralSelectTool: { svg: GeneralSelectTool, size: 24 },
RasterImaginateTool: { svg: RasterImaginateTool, size: 24 },
RasterNodesTool: { svg: RasterNodesTool, size: 24 },
RasterBrushTool: { svg: RasterBrushTool, size: 24 },
RasterCloneTool: { svg: RasterCloneTool, size: 24 },
RasterDetailTool: { svg: RasterDetailTool, size: 24 },
RasterHealTool: { svg: RasterHealTool, size: 24 },
RasterPatchTool: { svg: RasterPatchTool, size: 24 },
RasterRelightTool: { svg: RasterRelightTool, size: 24 },
VectorEllipseTool: { svg: VectorEllipseTool, size: 24 },
VectorFreehandTool: { svg: VectorFreehandTool, size: 24 },
VectorLineTool: { svg: VectorLineTool, size: 24 },
VectorPathTool: { svg: VectorPathTool, size: 24 },
VectorPenTool: { svg: VectorPenTool, size: 24 },
VectorRectangleTool: { svg: VectorRectangleTool, size: 24 },
VectorShapeTool: { svg: VectorShapeTool, size: 24 },
VectorSplineTool: { svg: VectorSplineTool, size: 24 },
VectorTextTool: { svg: VectorTextTool, size: 24 },
} as const;
// All icons
const ICON_LIST = {
...GRAPHICS,
...SOLID_12PX,
...SOLID_16PX,
...TWO_TONE_16PX,
...TWO_TONE_24PX,
} as const;
// Exported icons and types
export const ICONS: IconDefinitionType<typeof ICON_LIST> = ICON_LIST;
export const ICON_SVG_STRINGS = Object.fromEntries(Object.entries(ICONS).map(([name, data]) => [name, data.svg]));
export type IconName = keyof typeof ICONS;
export type IconSize = undefined | 12 | 16 | 24 | 32;
// The following helper type declarations allow us to avoid manually maintaining the `IconName` type declaration as a string union paralleling the keys of the
// icon definitions. It lets TypeScript do that for us. Our goal is to define the big key-value pair of icons by constraining its values, but inferring its keys.
// Constraining its values means that TypeScript can make sure each icon definition has a valid size number from the union of numbers that is `IconSize`.
// Inferring its keys means we don't have to specify a supertype like `string` or `any` for the key-value pair's keys, which would prevent us from accessing
// the individual keys with `keyof typeof`. Absent a specified type for the keys, TypeScript falls back to inferring that the key-value pair's type is the
// map of all its individual entries. Having the full list of entries lets us automatically set the `IconName` type to the union of strings that is the full
// list of keys. The result is that we don't have to maintain a separate list of icon names since this scheme infers it from the keys of the icon definitions.
// Based on https://stackoverflow.com/a/64119715/775283
type IconDefinition = { svg: string; size: IconSize };
type EvaluateType<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
type IconDefinitionType<T extends Record<string, IconDefinition>> = EvaluateType<{ [key in keyof T]: IconDefinition }>;
@@ -0,0 +1,367 @@
/* eslint-disable camelcase */
// import { escapeJSON } from "@/utility-functions/escape";
import { blobToBase64 } from "@/utility-functions/files";
import { type RequestResult, requestWithUploadDownloadProgress } from "@/utility-functions/network";
import { type Editor } from "@/wasm-communication/editor";
import type { XY } from "@/wasm-communication/messages";
import { type ImaginateGenerationParameters } from "@/wasm-communication/messages";
const MAX_POLLING_RETRIES = 4;
const SERVER_STATUS_CHECK_TIMEOUT = 5000;
const PROGRESS_EVERY_N_STEPS = 5;
let timer: NodeJS.Timeout | undefined;
let terminated = false;
let generatingAbortRequest: XMLHttpRequest | undefined;
let pollingAbortController = new AbortController();
let statusAbortController = new AbortController();
// PUBLICLY CALLABLE FUNCTIONS
export async function imaginateGenerate(
parameters: ImaginateGenerationParameters,
image: Blob | undefined,
mask: Blob | undefined,
maskPaintMode: string,
maskBlurPx: number,
maskFillContent: string,
hostname: string,
refreshFrequency: number,
documentId: bigint,
layerPath: BigUint64Array,
nodePath: BigUint64Array,
editor: Editor
): Promise<void> {
// Ignore a request to generate a new image while another is already being generated
if (generatingAbortRequest !== undefined) return;
terminated = false;
// Immediately set the progress to 0% so the backend knows to update its layout
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, 0, "Beginning");
// Initiate a request to the computation server
const discloseUploadingProgress = (progress: number): void => {
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, progress * 100, "Uploading");
};
const { uploaded, result, xhr } = await generate(discloseUploadingProgress, hostname, image, mask, maskPaintMode, maskBlurPx, maskFillContent, parameters);
generatingAbortRequest = xhr;
try {
// Wait until the request is fully uploaded, which could be slow if the img2img source is large and the user is on a slow connection
await uploaded;
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, 0, "Generating");
// Begin polling for updates to the in-progress image generation at the specified interval
// Don't poll if the chosen interval is 0, or if the chosen sampling method does not support polling
if (refreshFrequency > 0) {
const interval = Math.max(refreshFrequency * 1000, 500);
scheduleNextPollingUpdate(interval, Date.now(), 0, editor, hostname, documentId, layerPath, nodePath, parameters.resolution);
}
// Wait for the final image to be returned by the initial request containing either the full image or the last frame if it was terminated by the user
const { body, status } = await result;
if (status < 200 || status > 299) {
throw new Error(`Request to server failed to return a 200-level status code (${status})`);
}
// Extract the final image from the response and convert it to a data blob
const base64Data = JSON.parse(body)?.images?.[0] as string | undefined;
const base64 = typeof base64Data === "string" && base64Data.length > 0 ? `data:image/png;base64,${base64Data}` : undefined;
if (!base64) throw new Error("Could not read final image result from server response");
const blob = await (await fetch(base64)).blob();
// Send the backend an updated status
const percent = terminated ? undefined : 100;
const newStatus = terminated ? "Terminated" : "Idle";
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, percent, newStatus);
// Send the backend a blob URL for the final image
updateBackendImage(editor, blob, documentId, layerPath, nodePath);
} catch {
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, undefined, "Terminated");
await imaginateCheckConnection(hostname, editor);
}
abortAndResetGenerating();
abortAndResetPolling();
}
export async function imaginateTerminate(hostname: string, documentId: bigint, layerPath: BigUint64Array, nodePath: BigUint64Array, editor: Editor): Promise<void> {
terminated = true;
abortAndResetPolling();
try {
await terminate(hostname);
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, undefined, "Terminating");
} catch {
abortAndResetGenerating();
abortAndResetPolling();
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, undefined, "Terminated");
await imaginateCheckConnection(hostname, editor);
}
}
export async function imaginateCheckConnection(hostname: string, editor: Editor): Promise<void> {
const serverReached = await checkConnection(hostname);
editor.instance.setImaginateServerStatus(serverReached);
}
// Converts the blob image into a list of pixels using an invisible canvas.
export async function updateBackendImage(editor: Editor, blob: Blob, documentId: bigint, layerPath: BigUint64Array, nodePath: BigUint64Array): Promise<void> {
const image = await createImageBitmap(blob);
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not create canvas context");
ctx.drawImage(image, 0, 0);
// Send the backend the blob data to be stored persistently in the layer
const imageData = ctx.getImageData(0, 0, image.width, image.height);
const u8Array = new Uint8Array(imageData.data);
editor.instance.setImaginateImageData(documentId, layerPath, nodePath, u8Array, imageData.width, imageData.height);
}
// ABORTING AND RESETTING HELPERS
function abortAndResetGenerating(): void {
generatingAbortRequest?.abort();
generatingAbortRequest = undefined;
}
function abortAndResetPolling(): void {
pollingAbortController.abort();
pollingAbortController = new AbortController();
clearTimeout(timer);
}
// POLLING IMPLEMENTATION DETAILS
function scheduleNextPollingUpdate(
interval: number,
timeoutBegan: number,
pollingRetries: number,
editor: Editor,
hostname: string,
documentId: bigint,
layerPath: BigUint64Array,
nodePath: BigUint64Array,
resolution: XY
): void {
// Pick a future time that keeps to the user-requested interval if possible, but on slower connections will go as fast as possible without overlapping itself
const nextPollTimeGoal = timeoutBegan + interval;
const timeFromNow = Math.max(0, nextPollTimeGoal - Date.now());
timer = setTimeout(async () => {
const nextTimeoutBegan = Date.now();
try {
const [blob, percentComplete] = await pollImage(hostname);
// After waiting for the polling result back from the server, if during that intervening time the user has terminated the generation, exit so we don't overwrite that terminated status
if (terminated) return;
if (blob) updateBackendImage(editor, blob, documentId, layerPath, nodePath);
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, nodePath, percentComplete, "Generating");
scheduleNextPollingUpdate(interval, nextTimeoutBegan, 0, editor, hostname, documentId, layerPath, nodePath, resolution);
} catch {
if (generatingAbortRequest === undefined) return;
if (pollingRetries + 1 > MAX_POLLING_RETRIES) {
abortAndResetGenerating();
abortAndResetPolling();
await imaginateCheckConnection(hostname, editor);
} else {
scheduleNextPollingUpdate(interval, nextTimeoutBegan, pollingRetries + 1, editor, hostname, documentId, layerPath, nodePath, resolution);
}
}
}, timeFromNow);
}
// API COMMUNICATION FUNCTIONS
async function pollImage(hostname: string): Promise<[Blob | undefined, number]> {
// Fetch the percent progress and in-progress image from the API
const result = await fetch(`${hostname}sdapi/v1/progress`, { signal: pollingAbortController.signal, method: "GET" });
const { current_image, progress } = await result.json();
// Convert to a usable format
const progressPercent = progress * 100;
const base64 = typeof current_image === "string" && current_image.length > 0 ? `data:image/png;base64,${current_image}` : undefined;
// Deal with a missing image
if (!base64) {
// The image is not ready yet (because it's only had a few samples since generation began), but we do have a progress percentage
if (!Number.isNaN(progressPercent) && progressPercent >= 0 && progressPercent <= 100) {
return [undefined, progressPercent];
}
// Something else is wrong and the image wasn't provided as expected
return Promise.reject();
}
// The image was provided so we turn it into a data blob
const blob = await (await fetch(base64)).blob();
return [blob, progressPercent];
}
async function generate(
discloseUploadingProgress: (progress: number) => void,
hostname: string,
image: Blob | undefined,
mask: Blob | undefined,
maskPaintMode: string,
maskBlurPx: number,
maskFillContent: string,
parameters: ImaginateGenerationParameters
): Promise<{
uploaded: Promise<void>;
result: Promise<RequestResult>;
xhr?: XMLHttpRequest;
}> {
let body;
let endpoint;
if (image === undefined || parameters.denoisingStrength === undefined) {
endpoint = `${hostname}sdapi/v1/txt2img`;
body = {
// enable_hr: false,
// denoising_strength: 0,
// firstphase_width: 0,
// firstphase_height: 0,
prompt: parameters.prompt,
// styles: [],
seed: Number(parameters.seed),
// subseed: -1,
// subseed_strength: 0,
// seed_resize_from_h: -1,
// seed_resize_from_w: -1,
// batch_size: 1,
// n_iter: 1,
steps: parameters.samples,
cfg_scale: parameters.cfgScale,
width: parameters.resolution.x,
height: parameters.resolution.y,
restore_faces: parameters.restoreFaces,
tiling: parameters.tiling,
negative_prompt: parameters.negativePrompt,
// eta: 0,
// s_churn: 0,
// s_tmax: 0,
// s_tmin: 0,
// s_noise: 1,
override_settings: {
show_progress_every_n_steps: PROGRESS_EVERY_N_STEPS,
},
sampler_index: parameters.samplingMethod,
};
} else {
const sourceImageBase64 = await blobToBase64(image);
const maskImageBase64 = mask ? await blobToBase64(mask) : "";
const maskFillContentIndexes = ["Fill", "Original", "LatentNoise", "LatentNothing"];
const maskFillContentIndexFound = maskFillContentIndexes.indexOf(maskFillContent);
const maskFillContentIndex = maskFillContentIndexFound === -1 ? undefined : maskFillContentIndexFound;
const maskInvert = maskPaintMode === "Inpaint" ? 1 : 0;
endpoint = `${hostname}sdapi/v1/img2img`;
body = {
init_images: [sourceImageBase64],
// resize_mode: 0,
denoising_strength: parameters.denoisingStrength,
mask: mask && maskImageBase64,
mask_blur: mask && maskBlurPx,
inpainting_fill: mask && maskFillContentIndex,
inpaint_full_res: mask && false,
// inpaint_full_res_padding: 0,
inpainting_mask_invert: mask && maskInvert,
prompt: parameters.prompt,
// styles: [],
seed: Number(parameters.seed),
// subseed: -1,
// subseed_strength: 0,
// seed_resize_from_h: -1,
// seed_resize_from_w: -1,
// batch_size: 1,
// n_iter: 1,
steps: parameters.samples,
cfg_scale: parameters.cfgScale,
width: parameters.resolution.x,
height: parameters.resolution.y,
restore_faces: parameters.restoreFaces,
tiling: parameters.tiling,
negative_prompt: parameters.negativePrompt,
// eta: 0,
// s_churn: 0,
// s_tmax: 0,
// s_tmin: 0,
// s_noise: 1,
override_settings: {
show_progress_every_n_steps: PROGRESS_EVERY_N_STEPS,
img2img_fix_steps: true,
},
sampler_index: parameters.samplingMethod,
// include_init_images: false,
};
}
// Prepare a promise that will resolve after the outbound request upload is complete
let uploadedResolve: () => void;
let uploadedReject: () => void;
const uploaded = new Promise<void>((resolve, reject): void => {
uploadedResolve = resolve;
uploadedReject = reject;
});
// Fire off the request and, once the outbound request upload is complete, resolve the promise we defined above
const uploadProgress = (progress: number): void => {
if (progress < 1) {
discloseUploadingProgress(progress);
} else {
uploadedResolve();
}
};
const [result, xhr] = requestWithUploadDownloadProgress(endpoint, "POST", JSON.stringify(body), uploadProgress, abortAndResetPolling);
result.catch(() => uploadedReject());
// Return the promise that resolves when the request upload is complete, the promise that resolves when the response download is complete, and the XHR so it can be aborted
return { uploaded, result, xhr };
}
async function terminate(hostname: string): Promise<void> {
await fetch(`${hostname}sdapi/v1/interrupt`, { method: "POST" });
}
async function checkConnection(hostname: string): Promise<boolean> {
statusAbortController.abort();
statusAbortController = new AbortController();
const timeout = setTimeout(() => statusAbortController.abort(), SERVER_STATUS_CHECK_TIMEOUT);
try {
// Intentionally misuse this API endpoint by using it just to check for a code 200 response, regardless of what the result is
const { status } = await fetch(`${hostname}sdapi/v1/progress?skip_current_image=true`, { signal: statusAbortController.signal, method: "GET" });
// This code means the server has indeed responded and the endpoint exists (otherwise it would be 404)
if (status === 200) {
clearTimeout(timeout);
return true;
}
} catch {
// Do nothing here
}
return false;
}
@@ -0,0 +1,741 @@
export function makeKeyboardModifiersBitfield(e: WheelEvent | PointerEvent | KeyboardEvent): number {
return (
// Shift (all platforms)
(Number(e.shiftKey) << 0) |
// Alt (all platforms, also called Option on Mac)
(Number(e.altKey) << 1) |
// Control (all platforms)
(Number(e.ctrlKey) << 2) |
// Meta (Windows/Linux) or Command (Mac)
(Number(e.metaKey) << 3)
);
}
// Necessary because innerText puts an extra newline character at the end when the text is more than one line.
export function textInputCleanup(text: string): string {
if (text[text.length - 1] === "\n") return text.slice(0, -1);
return text;
}
// This function tries to find what scan code the user pressed, even if using a non-US keyboard.
// Directly using `KeyboardEvent.code` scan code only works on a US QWERTY layout, because alternate layouts like
// QWERTZ (German) or AZERTY (French) will end up reporting the wrong keys.
// Directly using `KeyboardEvent.key` doesn't work because the results are often garbage, as the printed character
// varies when the Shift key is pressed, or worse, when the Option (Alt) key on a Mac is pressed.
// This function does its best to try and sort through both of those sources of information to determine the localized scan code.
//
// This function is an imperfect stopgap solution to allow non-US keyboards to be handled on a best-effort basis.
// Eventually we will need a more robust system based on a giant database of keyboard layouts from all around the world.
// We'd provide the user a choice of layout, and aim to detect a default based on the `key` and `code` values entered by the user
// combined with `Keyboard.getLayoutMap()` where supported in Chromium-based browsers and perhaps the browser's language and IP address.
// We are also limited by browser APIs, since the spec doesn't support what we need it to:
// <https://github.com/WICG/keyboard-map/issues/26>
// In the desktop version of VS Code, this is achieved with this Electron plugin:
// <https://github.com/Microsoft/node-native-keymap>
// We may be able to port that (it's a relatively small codebase) to Rust for use with Tauri.
// But on the web, just like VS Code, we're limited by the shortcomings of the spec.
// A collection of further insights:
// <https://docs.google.com/document/d/1p17IBbYGsZivLIMhKZOaCJFAJFokbPfKrkB37fOPXSM/edit>
// And it's a really good idea to read the explainer on keyboard layout variations and the whole spec (it's quite digestible):
// <https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system>
export async function getLocalizedScanCode(e: KeyboardEvent): Promise<string> {
const keyText = e.key;
const scanCode = e.code;
// Use the key code directly if it isn't one that changes per locale (i.e. isn't a writing system key or one of the other few exceptions)
const scanCodeNotLocaleSpecific = !LOCALE_SPECIFIC_KEY_CODES.includes(scanCode);
if (scanCodeNotLocaleSpecific) {
return scanCode;
}
// Use the key directly if it's one of the exceptions that usually don't change, but sometimes do in a predictable way
if (SCAN_CODES_FOR_NON_WRITING_KEYS_THAT_VARY_PER_LOCALE.includes(scanCode)) {
// Numpad comma and period which swap in some locales as decimal and thousands separator symbols
if (NUMPAD_DECIMAL_AND_THOUSANDS_SEPARATORS.includes(scanCode)) {
switch (scanCode) {
case ".":
return "NumpadDecimal";
case ",":
return "NumpadComma";
default:
return scanCode;
}
}
// The AltRight key changes from a key value of "Alt" to "AltGraph" on keyboards with an AltGraph key
if (scanCode === "AltRight") {
return keyText === "Alt" ? "AltRight" : "AltGraph";
}
}
// Use good-enough-for-now heuristics on the writing system keys, which are commonly subject to change by locale
// Number scan codes
if (/^Digit[0-9]$/.test(scanCode)) {
// For now it's good enough to treat every digit key, regardless of locale, as just its digit from the standard US layout.
// Even on a keyboard like the French AZERTY layout, where numbers are shifted, users still refer to those keys by their numbers.
// This unfortunately means that any special symbols under these keys are overridden by their number, making it impossible to access some shortcuts that rely on those special symbols.
// We'll have to deal with that for now, and find a way to upgrade or properly replace this system, or assign alternate keymaps based on locale, when people complain.
return scanCode;
}
// Letter scan codes
if (/^Key([A-Z])$/.test(scanCode)) {
// Get the uppercase letter, with any accents or discritics removed if possible
const rawLetter = keyText
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toUpperCase();
// If the key letter is in the A-Z range, use the key code for that letter
if (/^[A-Z]$/.test(rawLetter)) return `Key${rawLetter}`;
// If the key text isn't one of the named attribute values, that means it must be the literal unicode value which we use directly
// It is likely a weird symbol that isn't in the A-Z range even with accents removed.
// It might be a symbol from an Option key combination on a Mac. Or it might be from a non-Latin alphabet like Cyrillic.
if (!KEY_ATTRIBUTE_VALUES.has(keyText)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (navigator && "keyboard" in navigator && "getLayoutMap" in (navigator as any).keyboard) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layout = await (navigator as any).keyboard.getLayoutMap();
type KeyCode = string;
type KeySymbol = string;
// Get all the keyboard mappings and transform the key symbols to uppercase
const keyboardLayoutMap: [KeyCode, KeySymbol][] = [...layout.entries()].map(([keyCode, keySymbol]) => [keyCode, keySymbol.toUpperCase()]);
// If we match the uppercase version of the pressed key character, use the scan code that produces it
const matchedEntry = keyboardLayoutMap.find(([_, keySymbol]) => keySymbol === keyText.toUpperCase());
if (matchedEntry) return matchedEntry[0];
}
// If the keyboard layout API is unavailable, or it didn't match anything, just return the scan code that the user typed
// This isn't perfect because alternate keyboard layouts may end up having the US QWERTY key,
// but it's all we can do without a giant database of keyboard layouts and Mac Option key combinations
return scanCode;
}
// If the key's named attribute value shares a name with a scan code, use that scan code
if (KEY_CODE_NAMES.includes(keyText)) {
return scanCodeFromKeyText(keyText);
}
if (KEY_ATTRIBUTE_VALUES_INVOLVING_HANDEDNESS.includes(keyText)) {
// Since for some reason we're in a situation where we are using the key instead of the scan code to
// match one of the modifier keys which have both a Left and Right variant as part of the scan code names,
// but no handedness as part of the key's named attribute values, we default to the left side as it's more common.
return `${keyText}Left`;
}
// All reasonable attempts to figure out what this key is has now failed, so we fall back on the US QWERTY layout scan code
return scanCode;
}
// If the key text is the unicode character for one of the standard symbols on the US keyboard, use the symbol even though it's not located on the same scan code as on a US keyboard
if (WRITING_SYSTEM_SPECIAL_CHARS.includes(keyText)) {
return scanCodeFromKeyText(keyText);
}
// If the key is otherwise totally unrecognized, we ignore it
if (keyText === "Unidentified" || scanCode === "Unidentified") return "Unidentified";
// As a last resort, we just use the scan code
return scanCode;
}
function scanCodeFromKeyText(keyText: string): string {
// There are many possible unicode symbols as well as named attribute values, but we only care about finding the equivalent scan code (based on the US keyboard) without regard for modifiers
// Match any handed modifier keys by claiming it's the left-handed modifier
if (KEY_ATTRIBUTE_VALUES_INVOLVING_HANDEDNESS.includes(keyText)) {
return `${keyText}Left`;
}
// Match any named attribute keys to an identical key name
const identicalName = KEY_CODE_NAMES.find((code) => code === keyText);
if (identicalName) return keyText;
// Match the space character
// Order matters because the next step assumes it can safely ignore the space character
if (keyText === " ") return "Space";
// Match individual characters by the scan code which produces that symbol on a US keyboard, either shifted or not
// This also includes the `Digit*` and `Key*` codes
const matchedScanCode = KEY_CODES.find((info) => info.keys?.us?.includes(keyText));
if (matchedScanCode) return matchedScanCode.code;
return "Unidentified";
}
type KeyCategories = "writing-system" | "functional" | "functional-jp-kr" | "control-pad" | "arrow-pad" | "numpad" | "function" | "media" | "unidentified";
type KeyboardLocale = "us";
type ScanCodeInfo = { code: string; category: KeyCategories; keys?: Record<KeyboardLocale, string | undefined> };
const KEY_CODES: ScanCodeInfo[] = [
// Writing system keys
// Codes produce different printed characters depending on locale
// https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system
{ code: "Digit0", category: "writing-system", keys: { us: "0 )" } },
{ code: "Digit1", category: "writing-system", keys: { us: "1 !" } },
{ code: "Digit2", category: "writing-system", keys: { us: "2 @" } },
{ code: "Digit3", category: "writing-system", keys: { us: "3 #" } },
{ code: "Digit4", category: "writing-system", keys: { us: "4 $" } },
{ code: "Digit5", category: "writing-system", keys: { us: "5 %" } },
{ code: "Digit6", category: "writing-system", keys: { us: "6 ^" } },
{ code: "Digit7", category: "writing-system", keys: { us: "7 &" } },
{ code: "Digit8", category: "writing-system", keys: { us: "8 *" } },
{ code: "Digit9", category: "writing-system", keys: { us: "9 (" } },
{ code: "KeyA", category: "writing-system", keys: { us: "a A" } },
{ code: "KeyB", category: "writing-system", keys: { us: "b B" } },
{ code: "KeyC", category: "writing-system", keys: { us: "c C" } },
{ code: "KeyD", category: "writing-system", keys: { us: "d D" } },
{ code: "KeyE", category: "writing-system", keys: { us: "e E" } },
{ code: "KeyF", category: "writing-system", keys: { us: "f F" } },
{ code: "KeyG", category: "writing-system", keys: { us: "g G" } },
{ code: "KeyH", category: "writing-system", keys: { us: "h H" } },
{ code: "KeyI", category: "writing-system", keys: { us: "i I" } },
{ code: "KeyJ", category: "writing-system", keys: { us: "j J" } },
{ code: "KeyK", category: "writing-system", keys: { us: "k K" } },
{ code: "KeyL", category: "writing-system", keys: { us: "l L" } },
{ code: "KeyM", category: "writing-system", keys: { us: "m M" } },
{ code: "KeyN", category: "writing-system", keys: { us: "n N" } },
{ code: "KeyO", category: "writing-system", keys: { us: "o O" } },
{ code: "KeyP", category: "writing-system", keys: { us: "p P" } },
{ code: "KeyQ", category: "writing-system", keys: { us: "q Q" } },
{ code: "KeyR", category: "writing-system", keys: { us: "r R" } },
{ code: "KeyS", category: "writing-system", keys: { us: "s S" } },
{ code: "KeyT", category: "writing-system", keys: { us: "t T" } },
{ code: "KeyU", category: "writing-system", keys: { us: "u U" } },
{ code: "KeyV", category: "writing-system", keys: { us: "v V" } },
{ code: "KeyW", category: "writing-system", keys: { us: "w W" } },
{ code: "KeyX", category: "writing-system", keys: { us: "x X" } },
{ code: "KeyY", category: "writing-system", keys: { us: "y Y" } },
{ code: "KeyZ", category: "writing-system", keys: { us: "z Z" } },
{ code: "Backquote", category: "writing-system", keys: { us: "` ~" } },
{ code: "Backslash", category: "writing-system", keys: { us: "\\ |" } },
{ code: "BracketLeft", category: "writing-system", keys: { us: "[ {" } },
{ code: "BracketRight", category: "writing-system", keys: { us: "] }" } },
{ code: "Comma", category: "writing-system", keys: { us: ", <" } },
{ code: "Equal", category: "writing-system", keys: { us: "= +" } },
{ code: "Minus", category: "writing-system", keys: { us: "- _" } },
{ code: "Period", category: "writing-system", keys: { us: ". >" } },
{ code: "Quote", category: "writing-system", keys: { us: "' \"" } },
{ code: "Semicolon", category: "writing-system", keys: { us: "; :" } },
{ code: "Slash", category: "writing-system", keys: { us: "/ ?" } },
{ code: "IntlBackslash", category: "writing-system", keys: { us: undefined } },
{ code: "IntlRo", category: "writing-system", keys: { us: undefined } },
{ code: "IntlYen", category: "writing-system", keys: { us: undefined } },
// Functional keys
// https://www.w3.org/TR/uievents-code/#key-alphanumeric-functional
// Codes have the same meaning regardless of locale, except for "AltRight"
{ code: "AltLeft", category: "functional" },
{ code: "AltRight", category: "functional" }, // Exception: `key` value is either "Alt" or "AltGraph" depending on locale (e.g. US vs. French, respectively)
// The W3C table includes this in the Writing System Keys table instead of the Functional Keys table, but its diagrams
// and text describe it as a functional key, so it has been moved here under the assumption that the table is incorrect
// https://github.com/w3c/uievents-code/issues/34
{ code: "Backspace", category: "writing-system" }, // Shares a name with a key attribute
{ code: "CapsLock", category: "functional" }, // Shares a name with a key attribute
{ code: "ContextMenu", category: "functional" }, // Shares a name with a key attribute
{ code: "ControlLeft", category: "functional" }, // Shares a name with a key attribute as "Control"
{ code: "ControlRight", category: "functional" }, // Shares a name with a key attribute as "Control"
{ code: "Enter", category: "functional" }, // Shares a name with a key attribute
{ code: "MetaLeft", category: "functional" }, // Shares a name with a key attribute as "Meta"
{ code: "MetaRight", category: "functional" }, // Shares a name with a key attribute as "Meta"
{ code: "ShiftLeft", category: "functional" }, // Shares a name with a key attribute as "Shift"
{ code: "ShiftRight", category: "functional" }, // Shares a name with a key attribute as "Shift"
{ code: "Space", category: "functional" },
{ code: "Tab", category: "functional" }, // Shares a name with a key attribute
// Functional Japanese/Korean keys
{ code: "Convert", category: "functional-jp-kr" }, // Shares a name with a key attribute
{ code: "KanaMode", category: "functional-jp-kr" }, // Shares a name with a key attribute
{ code: "Lang1", category: "functional-jp-kr" },
{ code: "Lang2", category: "functional-jp-kr" },
{ code: "Lang3", category: "functional-jp-kr" },
{ code: "Lang4", category: "functional-jp-kr" },
{ code: "Lang5", category: "functional-jp-kr" },
{ code: "NonConvert", category: "functional-jp-kr" }, // Shares a name with a key attribute
// Control pad keys
{ code: "Delete", category: "control-pad" }, // Shares a name with a key attribute
{ code: "End", category: "control-pad" }, // Shares a name with a key attribute
{ code: "Help", category: "control-pad" }, // Shares a name with a key attribute
{ code: "Home", category: "control-pad" }, // Shares a name with a key attribute
{ code: "Insert", category: "control-pad" }, // Shares a name with a key attribute
{ code: "PageDown", category: "control-pad" }, // Shares a name with a key attribute
{ code: "PageUp", category: "control-pad" }, // Shares a name with a key attribute
// Arrow pad keys
{ code: "ArrowDown", category: "arrow-pad" }, // Shares a name with a key attribute
{ code: "ArrowLeft", category: "arrow-pad" }, // Shares a name with a key attribute
{ code: "ArrowRight", category: "arrow-pad" }, // Shares a name with a key attribute
{ code: "ArrowUp", category: "arrow-pad" }, // Shares a name with a key attribute
// Numpad keys
{ code: "Numpad0", category: "numpad" },
{ code: "Numpad1", category: "numpad" },
{ code: "Numpad2", category: "numpad" },
{ code: "Numpad3", category: "numpad" },
{ code: "Numpad4", category: "numpad" },
{ code: "Numpad5", category: "numpad" },
{ code: "Numpad6", category: "numpad" },
{ code: "Numpad7", category: "numpad" },
{ code: "Numpad8", category: "numpad" },
{ code: "Numpad9", category: "numpad" },
{ code: "NumLock", category: "numpad" }, // Shares a name with a key attribute
{ code: "NumpadAdd", category: "numpad" },
{ code: "NumpadBackspace", category: "numpad" },
{ code: "NumpadClear", category: "numpad" },
{ code: "NumpadClearEntry", category: "numpad" },
{ code: "NumpadComma", category: "numpad" }, // Exception: Produces either a comma (,) or period (.) depending on locale (e.g. comma in US vs. period in Brazil)
{ code: "NumpadDecimal", category: "numpad" }, // Exception: Produces either a comma (,) or period (.) depending on locale (e.g. period in US vs. decimal in Brazil)
{ code: "NumpadDivide", category: "numpad" },
{ code: "NumpadEnter", category: "numpad" },
{ code: "NumpadEqual", category: "numpad" },
{ code: "NumpadHash", category: "numpad" },
{ code: "NumpadMemoryAdd", category: "numpad" },
{ code: "NumpadMemoryClear", category: "numpad" },
{ code: "NumpadMemoryRecall", category: "numpad" },
{ code: "NumpadMemoryStore", category: "numpad" },
{ code: "NumpadMemorySubtract", category: "numpad" },
{ code: "NumpadMultiply", category: "numpad" },
{ code: "NumpadParenLeft", category: "numpad" },
{ code: "NumpadParenRight", category: "numpad" },
{ code: "NumpadStar", category: "numpad" },
{ code: "NumpadSubtract", category: "numpad" },
// Function keys
{ code: "Escape", category: "function" }, // Shares a name with a key attribute
{ code: "F1", category: "function" }, // Shares a name with a key attribute
{ code: "F2", category: "function" }, // Shares a name with a key attribute
{ code: "F3", category: "function" }, // Shares a name with a key attribute
{ code: "F4", category: "function" }, // Shares a name with a key attribute
{ code: "F5", category: "function" }, // Shares a name with a key attribute
{ code: "F6", category: "function" }, // Shares a name with a key attribute
{ code: "F7", category: "function" }, // Shares a name with a key attribute
{ code: "F8", category: "function" }, // Shares a name with a key attribute
{ code: "F9", category: "function" }, // Shares a name with a key attribute
{ code: "F10", category: "function" }, // Shares a name with a key attribute
{ code: "F11", category: "function" }, // Shares a name with a key attribute
{ code: "F12", category: "function" }, // Shares a name with a key attribute
{ code: "F13", category: "function" }, // Shares a name with a key attribute
{ code: "F14", category: "function" }, // Shares a name with a key attribute
{ code: "F15", category: "function" }, // Shares a name with a key attribute
{ code: "F16", category: "function" }, // Shares a name with a key attribute
{ code: "F17", category: "function" }, // Shares a name with a key attribute
{ code: "F18", category: "function" }, // Shares a name with a key attribute
{ code: "F19", category: "function" }, // Shares a name with a key attribute
{ code: "F20", category: "function" }, // Shares a name with a key attribute
{ code: "F21", category: "function" }, // Shares a name with a key attribute
{ code: "F22", category: "function" }, // Shares a name with a key attribute
{ code: "F23", category: "function" }, // Shares a name with a key attribute
{ code: "F24", category: "function" }, // Shares a name with a key attribute
{ code: "Fn", category: "function" }, // Shares a name with a key attribute
{ code: "FnLock", category: "function" }, // Shares a name with a key attribute
{ code: "PrintScreen", category: "function" }, // Shares a name with a key attribute
{ code: "ScrollLock", category: "function" }, // Shares a name with a key attribute
{ code: "Pause", category: "function" }, // Shares a name with a key attribute
// Media keys
{ code: "BrowserBack", category: "media" }, // Shares a name with a key attribute
{ code: "BrowserFavorites", category: "media" }, // Shares a name with a key attribute
{ code: "BrowserForward", category: "media" }, // Shares a name with a key attribute
{ code: "BrowserHome", category: "media" }, // Shares a name with a key attribute
{ code: "BrowserRefresh", category: "media" }, // Shares a name with a key attribute
{ code: "BrowserSearch", category: "media" }, // Shares a name with a key attribute
{ code: "BrowserStop", category: "media" }, // Shares a name with a key attribute
{ code: "Eject", category: "media" }, // Shares a name with a key attribute
{ code: "LaunchApp1", category: "media" },
{ code: "LaunchApp2", category: "media" },
{ code: "LaunchMail", category: "media" }, // Shares a name with a key attribute
{ code: "MediaPlayPause", category: "media" }, // Shares a name with a key attribute
{ code: "MediaSelect", category: "media" },
{ code: "MediaStop", category: "media" }, // Shares a name with a key attribute
{ code: "MediaTrackNext", category: "media" }, // Shares a name with a key attribute
{ code: "MediaTrackPrevious", category: "media" }, // Shares a name with a key attribute
{ code: "Power", category: "media" }, // Shares a name with a key attribute
{ code: "Sleep", category: "media" },
{ code: "AudioVolumeDown", category: "media" }, // Shares a name with a key attribute
{ code: "AudioVolumeMute", category: "media" }, // Shares a name with a key attribute
{ code: "AudioVolumeUp", category: "media" }, // Shares a name with a key attribute
{ code: "WakeUp", category: "media" }, // Shares a name with a key attribute
// Unidentified keys
{ code: "Unidentified", category: "unidentified" }, // Shares a name with a key attribute
];
const KEY_CODE_NAMES = Object.values(KEY_CODES).map((info) => info.code);
// const KEY_CODE_NAMES_WITHOUT_HANDEDNESS = KEY_CODE_NAMES.filter((code) => !(code.endsWith("Right") && HANDED_KEY_ATTRIBUTE_VALUES.some((modifier) => code === `${modifier}Right`))).map((code) =>
// code.endsWith("Left") && HANDED_KEY_ATTRIBUTE_VALUES.some((modifier) => code === `${modifier}Left`) ? code.replace("Left", "") : code
// );
const NUMPAD_DECIMAL_AND_THOUSANDS_SEPARATORS = ["NumpadComma", "NumpadDecimal"];
const SCAN_CODES_FOR_NON_WRITING_KEYS_THAT_VARY_PER_LOCALE = ["AltRight", ...NUMPAD_DECIMAL_AND_THOUSANDS_SEPARATORS];
const LOCALE_SPECIFIC_KEY_CODES_INFO = KEY_CODES.filter((key) => key.category === "writing-system" || SCAN_CODES_FOR_NON_WRITING_KEYS_THAT_VARY_PER_LOCALE.includes(key.code));
const LOCALE_SPECIFIC_KEY_CODES = LOCALE_SPECIFIC_KEY_CODES_INFO.map((info) => info.code);
const WRITING_SYSTEM_SPECIAL_CHARS = Object.values(KEY_CODES)
.filter((info) => info.category === "writing-system")
.flatMap((info) => info.keys?.us?.split(" "))
.filter((character) => character && !/[a-zA-Z0-9]/.test(character)) as string[];
const KEY_ATTRIBUTE_VALUES_INVOLVING_HANDEDNESS = ["Control", "Meta", "Shift"];
const KEY_ATTRIBUTE_VALUES = new Set([
// Modifier
"Alt", // Glyph modifier key
"AltGraph", // Glyph modifier key
"CapsLock", // Glyph modifier key
"Control",
"Fn",
"FnLock",
"Meta",
"NumLock",
"ScrollLock",
"Shift",
"Symbol",
"SymbolLock",
// Legacy modifier
"Hyper",
"Super",
// White space
"Enter", // Control character
"Tab", // Control character
// Navigation
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"End",
"Home",
"PageDown",
"PageUp",
// Editing
"Backspace", // Control character
"Clear",
"Copy",
"CrSel",
"Cut",
"Delete", // Control character
"EraseEof",
"ExSel",
"Insert",
"Paste",
"Redo",
"Undo",
// UI
"Accept",
"Again",
"Attn",
"Cancel",
"ContextMenu",
"Escape", // Control character
"Execute",
"Find",
"Help",
"Pause",
"Play",
"Props",
"Select",
"ZoomIn",
"ZoomOut",
// Device
"BrightnessDown",
"BrightnessUp",
"Eject",
"LogOff",
"Power",
"PowerOff",
"PrintScreen",
"Hibernate",
"Standby",
"WakeUp",
// IME composition keys
"AllCandidates",
"Alphanumeric",
"CodeInput",
"Compose",
"Convert",
"Dead",
"FinalMode",
"GroupFirst",
"GroupLast",
"GroupNext",
"GroupPrevious",
"ModeChange",
"NextCandidate",
"NonConvert",
"PreviousCandidate",
"Process",
"SingleCandidate",
// Korean-specific
"HangulMode",
"HanjaMode",
"JunjaMode",
// Japanese-specific
"Eisu",
"Hankaku",
"Hiragana",
"HiraganaKatakana",
"KanaMode",
"KanjiMode",
"Katakana",
"Romaji",
"Zenkaku",
"ZenkakuHankaku",
// Common function
"F1",
"F2",
"F3",
"F4",
"F5",
"F6",
"F7",
"F8",
"F9",
"F10",
"F11",
"F12",
"F13",
"F14",
"F15",
"F16",
"F17",
"F18",
"F19",
"F20",
"F21",
"F22",
"F23",
"F24",
"Soft1",
"Soft2",
"Soft3",
"Soft4",
"Soft5",
"Soft6",
"Soft7",
"Soft8",
"Soft9",
"Soft10",
"Soft11",
"Soft12",
"Soft13",
"Soft14",
"Soft15",
"Soft16",
"Soft17",
"Soft18",
"Soft19",
"Soft20",
"Soft21",
"Soft22",
"Soft23",
"Soft24",
// Multimedia
"ChannelDown",
"ChannelUp",
"Close",
"MailForward",
"MailReply",
"MailSend",
"MediaClose",
"MediaFastForward",
"MediaPause",
"MediaPlay",
"MediaPlayPause",
"MediaRecord",
"MediaRewind",
"MediaStop",
"MediaTrackNext",
"MediaTrackPrevious",
"New",
"Open",
"Print",
"Save",
"SpellCheck",
// Multimedia numpad
"Digit11",
"Digit12",
// Audio
"AudioBalanceLeft",
"AudioBalanceRight",
"AudioBassBoostDown",
"AudioBassBoostToggle",
"AudioBassBoostUp",
"AudioFaderFront",
"AudioFaderRear",
"AudioSurroundModeNext",
"AudioTrebleDown",
"AudioTrebleUp",
"AudioVolumeDown",
"AudioVolumeUp",
"AudioVolumeMute",
"MicrophoneToggle",
"MicrophoneVolumeDown",
"MicrophoneVolumeUp",
"MicrophoneVolumeMute",
// Speech
"SpeechCorrectionList",
"SpeechInputToggle",
// Application
"LaunchApplication1",
"LaunchApplication2",
"LaunchCalendar",
"LaunchContacts",
"LaunchMail",
"LaunchMediaPlayer",
"LaunchMusicPlayer",
"LaunchPhone",
"LaunchScreenSaver",
"LaunchSpreadsheet",
"LaunchWebBrowser",
"LaunchWebCam",
"LaunchWordProcessor",
// Browser
"BrowserBack",
"BrowserFavorites",
"BrowserForward",
"BrowserHome",
"BrowserRefresh",
"BrowserSearch",
"BrowserStop",
// Mobile phone
"AppSwitch",
"Call",
"Camera",
"CameraFocus",
"EndCall",
"GoBack",
"GoHome",
"HeadsetHook",
"LastNumberRedial",
"Notification",
"MannerMode",
"VoiceDial",
// TV
"TV",
"TV3DMode",
"TVAntennaCable",
"TVAudioDescription",
"TVAudioDescriptionMixDown",
"TVAudioDescriptionMixUp",
"TVContentsMenu",
"TVDataService",
"TVInput",
"TVInputComponent1",
"TVInputComponent2",
"TVInputComposite1",
"TVInputComposite2",
"TVInputHDMI1",
"TVInputHDMI2",
"TVInputHDMI3",
"TVInputHDMI4",
"TVInputVGA1",
"TVMediaContext",
"TVNetwork",
"TVNumberEntry",
"TVPower",
"TVRadioService",
"TVSatellite",
"TVSatelliteBS",
"TVSatelliteCS",
"TVSatelliteToggle",
"TVTerrestrialAnalog",
"TVTerrestrialDigital",
"TVTimer",
// Media controls
"AVRInput",
"AVRPower",
"ColorF0Red",
"ColorF1Green",
"ColorF2Yellow",
"ColorF3Blue",
"ColorF4Grey",
"ColorF5Brown",
"ClosedCaptionToggle",
"Dimmer",
"DisplaySwap",
"DVR",
"Exit",
"FavoriteClear0",
"FavoriteClear1",
"FavoriteClear2",
"FavoriteClear3",
"FavoriteRecall0",
"FavoriteRecall1",
"FavoriteRecall2",
"FavoriteRecall3",
"FavoriteStore0",
"FavoriteStore1",
"FavoriteStore2",
"FavoriteStore3",
"Guide",
"GuideNextDay",
"GuidePreviousDay",
"Info",
"InstantReplay",
"Link",
"ListProgram",
"LiveContent",
"Lock",
"MediaApps",
"MediaAudioTrack",
"MediaLast",
"MediaSkipBackward",
"MediaSkipForward",
"MediaStepBackward",
"MediaStepForward",
"MediaTopMenu",
"NavigateIn",
"NavigateNext",
"NavigateOut",
"NavigatePrevious",
"NextFavoriteChannel",
"NextUserProfile",
"OnDemand",
"Pairing",
"PinPDown",
"PinPMove",
"PinPToggle",
"PinPUp",
"PlaySpeedDown",
"PlaySpeedReset",
"PlaySpeedUp",
"RandomToggle",
"RcLowBattery",
"RecordSpeedNext",
"RfBypass",
"ScanChannelsToggle",
"ScreenModeNext",
"Settings",
"SplitScreenToggle",
"STBInput",
"STBPower",
"Subtitle",
"Teletext",
"VideoModeNext",
"Wink",
"ZoomToggle",
// Unidentified
"Unidentified",
]);
@@ -0,0 +1,3 @@
export function clamp(value: number, min = 0, max = 1): number {
return Math.max(min, Math.min(value, max));
}
@@ -0,0 +1,33 @@
export type RequestResult = { body: string; status: number };
// Special implementation using the legacy XMLHttpRequest API that provides callbacks to get:
// - Calls with the percent progress uploading the request to the server
// - Calls when downloading the result from the server, after the server has begun streaming back the response data
// It returns a tuple of the promise as well as the XHR which can be used to call the `.abort()` method on it.
export function requestWithUploadDownloadProgress(
url: string,
method: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH",
body: string,
uploadProgress: (progress: number) => void,
downloadOccurring: () => void
): [Promise<RequestResult>, XMLHttpRequest | undefined] {
let xhrValue: XMLHttpRequest | undefined;
const promise = new Promise<RequestResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => uploadProgress(e.loaded / e.total));
xhr.addEventListener("progress", () => downloadOccurring());
xhr.addEventListener("load", () => resolve({ status: xhr.status, body: xhr.responseText }));
xhr.addEventListener("abort", () => resolve({ status: xhr.status, body: xhr.responseText }));
xhr.addEventListener("error", () => reject(new Error("Request error")));
xhr.open(method, url, true);
xhr.setRequestHeader("accept", "*/*");
xhr.setRequestHeader("accept-language", "en-US,en;q=0.9");
xhr.setRequestHeader("content-type", "application/json");
xhrValue = xhr;
xhr.send(body);
});
return [promise, xhrValue];
}
@@ -0,0 +1,44 @@
// This works by proxying every function call and wrapping a try-catch block to filter out redundant and confusing
// `RuntimeError: unreachable` exceptions that would normally be printed in the browser's JS console upon a panic.
export function panicProxy<T extends object>(module: T): T {
const proxyHandler = {
get(target: T, propKey: string | symbol, receiver: unknown): unknown {
const targetValue = Reflect.get(target, propKey, receiver);
// Keep the original value being accessed if it isn't a function
const isFunction = typeof targetValue === "function";
if (!isFunction) return targetValue;
// Special handling to wrap the return of a constructor in the proxy
const isClass = isFunction && /^\s*class\s+/.test(targetValue.toString());
if (isClass) {
// eslint-disable-next-line func-names
return function (...args: unknown[]): unknown {
// All three of these comment lines are necessary to suppress errors at both compile time and while editing this file (@ts-expect-error doesn't work here while editing the file)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line new-cap
const result = new targetValue(...args);
return panicProxy(result);
};
}
// Replace the original function with a wrapper function that runs the original in a try-catch block
// eslint-disable-next-line func-names
return function (...args: unknown[]): unknown {
let result;
try {
// @ts-expect-error TypeScript does not know what `this` is, since it should be able to be anything
result = targetValue.apply(this, args);
} catch (err) {
// Suppress `unreachable` WebAssembly.RuntimeError exceptions
if (!`${err}`.startsWith("RuntimeError: unreachable")) throw err;
}
return result;
};
},
};
return new Proxy<T>(module, proxyHandler);
}
@@ -0,0 +1,54 @@
export function browserVersion(): string {
const agent = window.navigator.userAgent;
let match = agent.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
if (/trident/i.test(match[1])) {
const browser = /\brv[ :]+(\d+)/g.exec(agent) || [];
return `IE ${browser[1] || ""}`.trim();
}
if (match[1] === "Chrome") {
let browser = agent.match(/\bEdg\/(\d+)/) || undefined;
if (browser !== undefined) return `Edge (Chromium) ${browser[1]}`;
browser = agent.match(/\bOPR\/(\d+)/) || undefined;
if (browser !== undefined) return `Opera ${browser[1]}`;
}
match = match[2] ? [match[1], match[2]] : [navigator.appName, navigator.appVersion, "-?"];
const browser = agent.match(/version\/(\d+)/i) || undefined;
if (browser !== undefined) match.splice(1, 1, browser[1]);
return `${match[0]} ${match[1]}`;
}
export function operatingSystem(detailed = false): string {
const osTableDetailed: Record<string, string> = {
"Windows NT 10": "Windows 10 or 11",
"Windows NT 6.3": "Windows 8.1",
"Windows NT 6.2": "Windows 8",
"Windows NT 6.1": "Windows 7",
"Windows NT 6.0": "Windows Vista",
"Windows NT 5.1": "Windows XP",
"Windows NT 5.0": "Windows 2000",
Mac: "Mac",
X11: "Unix",
Linux: "Linux",
Unknown: "Unknown",
};
const osTableSimple: Record<string, string> = {
Windows: "Windows",
Mac: "Mac",
Linux: "Linux",
Unknown: "Unknown",
};
const osTable = detailed ? osTableDetailed : osTableSimple;
const userAgentOS = Object.keys(osTable).find((key) => window.navigator.userAgent.includes(key));
return osTable[userAgentOS || "Unknown"];
}
export function platformIsMac(): boolean {
return operatingSystem() === "Mac";
}
@@ -0,0 +1,53 @@
import { replaceBlobURLsWithBase64 } from "@/utility-functions/files";
// Rasterize the string of an SVG document at a given width and height and turn it into the blob data of an image file matching the given MIME type
export async function rasterizeSVGCanvas(svg: string, width: number, height: number, backgroundColor?: string): Promise<HTMLCanvasElement> {
// A canvas to render our SVG to in order to get a raster image
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("Can't create 2D context from canvas during SVG rasterization");
// Apply a background fill color if one is given
if (backgroundColor) {
context.fillStyle = backgroundColor;
context.fillRect(0, 0, width, height);
}
// This SVG rasterization scheme has the limitation that it cannot access blob URLs, so they must be inlined to base64 URLs
const svgWithBase64Images = await replaceBlobURLsWithBase64(svg);
// Create a blob URL for our SVG
const svgBlob = new Blob([svgWithBase64Images], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
const image = new Image();
image.src = url;
await new Promise<void>((resolve) => {
image.onload = (): void => resolve();
});
// Draw our SVG to the canvas
context?.drawImage(image, 0, 0, width, height);
// Clean up the SVG blob URL (once the URL is revoked, the SVG blob data itself is garbage collected after `svgBlob` goes out of scope)
URL.revokeObjectURL(url);
return canvas;
}
export async function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise<Blob> {
const canvas = await rasterizeSVGCanvas(svg, width, height, backgroundColor);
// Convert the canvas to an image of the correct MIME type
const blob = await new Promise<Blob | undefined>((resolve) => {
canvas.toBlob((blob) => {
resolve(blob || undefined);
}, mime);
});
if (!blob) throw new Error("Converting canvas to blob data failed in rasterizeSVG()");
return blob;
}
@@ -0,0 +1,12 @@
export function stripIndents(stringPieces: TemplateStringsArray, ...substitutions: unknown[]): string {
const interleavedSubstitutions = stringPieces.flatMap((stringPiece, index) => [stringPiece, substitutions[index] !== undefined ? substitutions[index] : ""]);
const stringLines = interleavedSubstitutions.join("").split("\n");
const visibleLineTabPrefixLengths = stringLines.map((line) => (/\S/.test(line) ? (line.match(/^(\t*)/) || [])[1].length : Infinity));
const commonTabPrefixLength = Math.min(...visibleLineTabPrefixLengths);
const linesWithoutCommonTabPrefix = stringLines.map((line) => line.substring(commonTabPrefixLength));
const multiLineString = linesWithoutCommonTabPrefix.join("\n");
return multiLineString.trim();
}