Anki/ts/routes/image-occlusion/mask-editor.ts
Damien Elmes 9f55cf26fc
Switch to SvelteKit (#3077)
* Update to latest Node LTS

* Add sveltekit

* Split tslib into separate @generated and @tslib components

SvelteKit's path aliases don't support multiple locations, so our old
approach of using @tslib to refer to both ts/lib and out/ts/lib will no
longer work. Instead, all generated sources and their includes are
placed in a separate out/ts/generated folder, and imported via @generated
instead. This also allows us to generate .ts files, instead of needing
to output separate .d.ts and .js files.

* Switch package.json to module type

* Avoid usage of baseUrl

Incompatible with SvelteKit

* Move sass into ts; use relative links

SvelteKit's default sass support doesn't allow overriding loadPaths

* jest->vitest, graphs example working with yarn dev

* most pages working in dev mode

* Some fixes after rebasing

* Fix/silence some svelte-check errors

* Get image-occlusion working with Fabric types

* Post-rebase lock changes

* Editor is now checked

* SvelteKit build integrated into ninja

* Use the new SvelteKit entrypoint for pages like congrats/deck options/etc

* Run eslint once for ts/**; fix some tests

* Fix a bunch of issues introduced when rebasing over latest main

* Run eslint fix

* Fix remaining eslint+pylint issues; tests now all pass

* Fix some issues with a clean build

* Latest bufbuild no longer requires @__PURE__ hack

* Add a few missed dependencies

* Add yarn.bat to fix Windows build

* Fix pages failing to show when ANKI_API_PORT not defined

* Fix svelte-check and vitest on Windows

* Set node path in ./yarn

* Move svelte-kit output to ts/.svelte-kit

Sadly, I couldn't figure out a way to store it in out/ if out/ is
a symlink, as it breaks module resolution when SvelteKit is run.

* Allow HMR inside Anki

* Skip SvelteKit build when HMR is defined

* Fix some post-rebase issues

I should have done a normal merge instead.
2024-03-31 09:16:31 +01:00

205 lines
7.1 KiB
TypeScript

// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { protoBase64 } from "@bufbuild/protobuf";
import { getImageForOcclusion, getImageOcclusionNote } from "@generated/backend";
import * as tr from "@generated/ftl";
import { fabric } from "fabric";
import { get } from "svelte/store";
import { optimumCssSizeForCanvas } from "./canvas-scale";
import { notesDataStore, tagsWritable } from "./store";
import Toast from "./Toast.svelte";
import { addShapesToCanvasFromCloze } from "./tools/add-from-cloze";
import { enableSelectable, makeShapeRemainInCanvas, moveShapeToCanvasBoundaries } from "./tools/lib";
import { modifiedPolygon } from "./tools/tool-polygon";
import { undoStack } from "./tools/tool-undo-redo";
import { enablePinchZoom, onResize, setCanvasSize } from "./tools/tool-zoom";
import type { Size } from "./types";
export interface ImageLoadedEvent {
path?: string;
noteId?: bigint;
}
export const setupMaskEditor = async (
path: string,
onChange: () => void,
onImageLoaded: (event: ImageLoadedEvent) => void,
): Promise<fabric.Canvas> => {
const imageData = await getImageForOcclusion({ path });
const canvas = initCanvas(onChange);
// get image width and height
const image = document.getElementById("image") as HTMLImageElement;
image.src = getImageData(imageData.data!, path);
image.onload = function() {
const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize());
setCanvasSize(canvas);
onImageLoaded({ path });
setupBoundingBox(canvas, size);
undoStack.reset();
};
return canvas;
};
export const setupMaskEditorForEdit = async (
noteId: number,
onChange: () => void,
onImageLoaded: (event: ImageLoadedEvent) => void,
): Promise<fabric.Canvas> => {
const clozeNoteResponse = await getImageOcclusionNote({ noteId: BigInt(noteId) });
const kind = clozeNoteResponse.value?.case;
if (!kind || kind === "error") {
new Toast({
target: document.body,
props: {
message: tr.notetypesErrorGettingImagecloze(),
type: "error",
},
}).$set({ showToast: true });
throw "error getting cloze";
}
const clozeNote = clozeNoteResponse.value.value;
const canvas = initCanvas(onChange);
// get image width and height
const image = document.getElementById("image") as HTMLImageElement;
image.src = getImageData(clozeNote.imageData!, clozeNote.imageFileName!);
image.onload = async function() {
const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize());
setCanvasSize(canvas);
const boundingBox = setupBoundingBox(canvas, size);
addShapesToCanvasFromCloze(canvas, boundingBox, clozeNote.occlusions);
enableSelectable(canvas, true);
addClozeNotesToTextEditor(clozeNote.header, clozeNote.backExtra, clozeNote.tags);
undoStack.reset();
window.requestAnimationFrame(() => {
onImageLoaded({ noteId: BigInt(noteId) });
});
};
return canvas;
};
function initCanvas(onChange: () => void): fabric.Canvas {
const canvas = new fabric.Canvas("canvas");
tagsWritable.set([]);
globalThis.canvas = canvas;
undoStack.setCanvas(canvas);
// find object per-pixel basis rather than according to bounding box,
// allow click through transparent area
canvas.perPixelTargetFind = true;
// Disable uniform scaling
canvas.uniformScaling = false;
canvas.uniScaleKey = "none";
// disable rotation globally
delete fabric.Object.prototype.controls.mtr;
// disable object caching
fabric.Object.prototype.objectCaching = false;
// add a border to corner to handle blend of control
fabric.Object.prototype.transparentCorners = false;
fabric.Object.prototype.cornerStyle = "circle";
fabric.Object.prototype.cornerStrokeColor = "#000000";
fabric.Object.prototype.padding = 8;
canvas.on("object:modified", (evt) => {
if (evt.target instanceof fabric.Polygon) {
modifiedPolygon(canvas, evt.target);
undoStack.onObjectModified();
}
onChange();
});
canvas.on("object:removed", onChange);
return canvas;
}
const setupBoundingBox = (canvas: fabric.Canvas, size: Size): fabric.Rect => {
const boundingBox = new fabric.Rect({
fill: "transparent",
width: size.width,
height: size.height,
hasBorders: false,
hasControls: false,
lockMovementX: true,
lockMovementY: true,
selectable: false,
evented: false,
});
boundingBox["id"] = "boundingBox";
canvas.add(boundingBox);
onResize(canvas);
makeShapeRemainInCanvas(canvas, boundingBox);
moveShapeToCanvasBoundaries(canvas, boundingBox);
// enable pinch zoom for mobile devices
enablePinchZoom(canvas);
return boundingBox;
};
const getImageData = (imageData, path): string => {
const b64encoded = protoBase64.enc(imageData);
const extension = path.split(".").pop();
const mimeTypes = {
"jpg": "jpeg",
"jpeg": "jpeg",
"gif": "gif",
"svg": "svg+xml",
"webp": "webp",
"avif": "avif",
"png": "png",
};
const type = mimeTypes[extension] || "png";
return `data:image/${type};base64,${b64encoded}`;
};
const addClozeNotesToTextEditor = (header: string, backExtra: string, tags: string[]) => {
const noteFieldsData: { id: string; title: string; divValue: string; textareaValue: string }[] = get(
notesDataStore,
);
noteFieldsData[0].divValue = header;
noteFieldsData[1].divValue = backExtra;
noteFieldsData[0].textareaValue = header;
noteFieldsData[1].textareaValue = backExtra;
tagsWritable.set(tags);
noteFieldsData.forEach((note) => {
const divId = `${note.id}--div`;
const textAreaId = `${note.id}--textarea`;
const divElement = document.getElementById(divId)!;
const textAreaElement = document.getElementById(textAreaId)! as HTMLTextAreaElement;
divElement.innerHTML = note.divValue;
textAreaElement.value = note.textareaValue;
});
};
function containerSize(): Size {
const container = document.querySelector(".editor-main")!;
return {
width: container.clientWidth,
height: container.clientHeight,
};
}
export async function resetIOImage(path: string, onImageLoaded: (event: ImageLoadedEvent) => void) {
const imageData = await getImageForOcclusion({ path });
const image = document.getElementById("image") as HTMLImageElement;
image.src = getImageData(imageData.data!, path);
const canvas = globalThis.canvas;
image.onload = async function() {
const size = optimumCssSizeForCanvas(
{ width: image.naturalWidth, height: image.naturalHeight },
containerSize(),
);
image.width = size.width;
image.height = size.height;
setCanvasSize(canvas);
onImageLoaded({ path });
setupBoundingBox(canvas, size);
};
}
globalThis.resetIOImage = resetIOImage;