mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00

* 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.
205 lines
7.1 KiB
TypeScript
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;
|