This commit is contained in:
jariji 2025-11-03 17:12:00 -08:00 committed by GitHub
commit d92966ddb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 162 additions and 75 deletions

View file

@ -47,6 +47,7 @@ notetypes-toggle-masks = Toggle Masks
notetypes-image-occlusion-name = Image Occlusion
notetypes-hide-all-guess-one = Hide All, Guess One
notetypes-hide-one-guess-one = Hide One, Guess One
notetypes-hide-all-but-one = Hide All But One
notetypes-error-generating-cloze = An error occurred when generating an image occlusion note
notetypes-error-getting-imagecloze = An error occurred while fetching an image occlusion note
notetypes-error-loading-image-occlusion = Error loading image occlusion. Is your Anki version up to date?

View file

@ -69,6 +69,12 @@ message GetImageOcclusionNoteResponse {
uint32 ordinal = 2;
}
enum OcclusionMode {
HIDE_ONE = 0;
HIDE_ALL = 1;
HIDE_ALL_BUT_ONE = 2;
}
message ImageOcclusionNote {
bytes image_data = 1;
repeated ImageOcclusion occlusions = 2;
@ -76,7 +82,7 @@ message GetImageOcclusionNoteResponse {
string back_extra = 4;
repeated string tags = 5;
string image_file_name = 6;
bool occlude_inactive = 7;
OcclusionMode occlusion_mode = 7;
}
oneof value {

View file

@ -839,4 +839,22 @@ mod test {
let card2_html = reveal_cloze_text(text, 2, true);
assert!(card2_html.contains(r#"data-ordinal="1,2""#));
}
#[test]
fn image_occlusion_modes() {
// Mode 1 (HideAll): should include data-occludeinactive="1"
let hide_all = "{{c1::image-occlusion:rect:left=10:top=20:width=30:height=40:oi=1}}";
let html = reveal_cloze_text(hide_all, 1, true);
assert!(html.contains(r#"data-occludeInactive="1""#));
// Mode 2 (HideAllButOne): should include data-occludeinactive="2"
let hide_all_but_one = "{{c1::image-occlusion:rect:left=10:top=20:width=30:height=40:oi=2}}";
let html = reveal_cloze_text(hide_all_but_one, 1, true);
assert!(html.contains(r#"data-occludeInactive="2""#));
// Mode 0 (HideOne): should not include data-occludeinactive attribute
let hide_one = "{{c1::image-occlusion:rect:left=10:top=20:width=30:height=40}}";
let html = reveal_cloze_text(hide_one, 1, true);
assert!(!html.contains("data-occludeInactive"));
}
}

View file

@ -7,6 +7,7 @@ use std::path::PathBuf;
use anki_io::metadata;
use anki_io::read_file;
use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionNote;
use anki_proto::image_occlusion::get_image_occlusion_note_response::OcclusionMode;
use anki_proto::image_occlusion::get_image_occlusion_note_response::Value;
use anki_proto::image_occlusion::AddImageOcclusionNoteRequest;
use anki_proto::image_occlusion::GetImageForOcclusionResponse;
@ -97,14 +98,22 @@ impl Collection {
let idxs = nt.get_io_field_indexes()?;
cloze_note.occlusions = parse_image_occlusions(fields[idxs.occlusions as usize].as_str());
cloze_note.occlude_inactive = cloze_note.occlusions.iter().any(|oc| {
oc.shapes.iter().any(|sh| {
sh.properties
.iter()
.find(|p| p.name == "oi")
.is_some_and(|p| p.value == "1")
cloze_note.occlusion_mode = cloze_note
.occlusions
.iter()
.find_map(|oc| {
oc.shapes.iter().find_map(|sh| {
sh.properties
.iter()
.find(|p| p.name == "oi")
.and_then(|p| match p.value.as_str() {
"1" => Some(OcclusionMode::HideAll as i32),
"2" => Some(OcclusionMode::HideAllButOne as i32),
_ => None,
})
})
})
});
.unwrap_or(OcclusionMode::HideOne as i32);
cloze_note.header.clone_from(&fields[idxs.header as usize]);
cloze_note
.back_extra

View file

@ -423,9 +423,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { IOMode } from "../routes/image-occlusion/lib";
import { exportShapesToClozeDeletions } from "../routes/image-occlusion/shapes/to-cloze";
import {
hideAllGuessOne,
ioImageLoadedStore,
ioMaskEditorVisible,
occlusionMode,
} from "../routes/image-occlusion/store";
import CollapseLabel from "./CollapseLabel.svelte";
import * as oldEditorAdapter from "./old-editor-adapter";
@ -477,7 +477,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function saveOcclusions(): void {
if (isImageOcclusion && globalThis.canvas) {
const occlusionsData = exportShapesToClozeDeletions($hideAllGuessOne);
const occlusionsData = exportShapesToClozeDeletions($occlusionMode);
fieldStores[ioFields.occlusions].set(occlusionsData.clozes);
}
}

View file

@ -15,6 +15,8 @@ import AlignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?compone
import alignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?url";
import AlignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?component";
import alignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?url";
import CheckboxBlankOutline_ from "@mdi/svg/svg/checkbox-blank-outline.svg?component";
import checkboxBlankOutline_ from "@mdi/svg/svg/checkbox-blank-outline.svg?url";
import CheckCircle_ from "@mdi/svg/svg/check-circle.svg?component";
import checkCircle_ from "@mdi/svg/svg/check-circle.svg?url";
import ChevronDown_ from "@mdi/svg/svg/chevron-down.svg?component";
@ -251,6 +253,7 @@ export const underlineIcon = { url: underline_, component: Underline_ };
export const deleteIcon = { url: delete_, component: Delete_ };
export const inlineIcon = { url: inline_, component: Inline_ };
export const blockIcon = { url: block_, component: Block_ };
export const mdiCheckboxBlankOutline = { url: checkboxBlankOutline_, component: CheckboxBlankOutline_ };
export const mdiAlignHorizontalCenter = { url: alignHorizontalCenter_, component: AlignHorizontalCenter_ };
export const mdiAlignHorizontalLeft = { url: alignHorizontalLeft_, component: AlignHorizontalLeft_ };
export const mdiAlignHorizontalRight = { url: alignHorizontalRight_, component: AlignHorizontalRight_ };

View file

@ -16,6 +16,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import Icon from "$lib/components/Icon.svelte";
import IconButton from "$lib/components/IconButton.svelte";
import {
mdiCheckboxBlankOutline,
mdiEye,
mdiFormatAlignCenter,
mdiSquare,
@ -26,11 +27,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import WithFloating from "$lib/components/WithFloating.svelte";
import {
hideAllGuessOne,
ioMaskEditorVisible,
textEditingState,
saveNeededStore,
OcclusionMode,
occlusionMode,
opacityStateStore,
saveNeededStore,
textEditingState,
} from "./store";
import { get } from "svelte/store";
import { drawEllipse, drawPolygon, drawRectangle, drawText } from "./tools/index";
@ -228,8 +230,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
disablePan(canvas);
};
function changeOcclusionType(occlusionType: "all" | "one"): void {
$hideAllGuessOne = occlusionType === "all";
function changeOcclusionType(mode: OcclusionMode): void {
$occlusionMode = mode;
saveNeededStore.set(true);
}
@ -312,22 +314,34 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{iconSize}
on:click={() => (showFloating = !showFloating)}
>
<Icon icon={$hideAllGuessOne ? mdiViewDashboard : mdiSquare} />
<Icon
icon={$occlusionMode === OcclusionMode.HideAll
? mdiViewDashboard
: $occlusionMode === OcclusionMode.HideAllButOne
? mdiCheckboxBlankOutline
: mdiSquare}
/>
</IconButton>
<Popover slot="floating">
<DropdownItem
active={$hideAllGuessOne}
on:click={() => changeOcclusionType("all")}
active={$occlusionMode === OcclusionMode.HideAll}
on:click={() => changeOcclusionType(OcclusionMode.HideAll)}
>
<span>{tr.notetypesHideAllGuessOne()}</span>
</DropdownItem>
<DropdownItem
active={!$hideAllGuessOne}
on:click={() => changeOcclusionType("one")}
active={$occlusionMode === OcclusionMode.HideOne}
on:click={() => changeOcclusionType(OcclusionMode.HideOne)}
>
<span>{tr.notetypesHideOneGuessOne()}</span>
</DropdownItem>
<DropdownItem
active={$occlusionMode === OcclusionMode.HideAllButOne}
on:click={() => changeOcclusionType(OcclusionMode.HideAllButOne)}
>
<span>{tr.notetypesHideAllButOne()}</span>
</DropdownItem>
</Popover>
</WithFloating>

View file

@ -5,11 +5,11 @@ import { get } from "svelte/store";
import { addOrUpdateNote } from "../add-or-update-note.svelte";
import type { IOMode } from "../lib";
import { hideAllGuessOne } from "../store";
import { occlusionMode } from "../store";
import type { PageLoad } from "./$types";
async function save(): Promise<void> {
addOrUpdateNote(globalThis["anki"].imageOcclusion.mode, get(hideAllGuessOne));
addOrUpdateNote(globalThis["anki"].imageOcclusion.mode, get(occlusionMode));
}
export const load = (async ({ params }) => {

View file

@ -9,14 +9,14 @@ import { get } from "svelte/store";
import { mount } from "svelte";
import type { IOAddingMode, IOMode } from "./lib";
import { exportShapesToClozeDeletions } from "./shapes/to-cloze";
import { notesDataStore, tagsWritable } from "./store";
import { notesDataStore, OcclusionMode, tagsWritable } from "./store";
import Toast from "./Toast.svelte";
export const addOrUpdateNote = async function(
mode: IOMode,
occludeInactive: boolean,
occlusionMode: OcclusionMode,
): Promise<void> {
const { clozes: occlusionCloze, noteCount } = exportShapesToClozeDeletions(occludeInactive);
const { clozes: occlusionCloze, noteCount } = exportShapesToClozeDeletions(occlusionMode);
if (noteCount === 0) {
return;
}

View file

@ -10,7 +10,7 @@ import { get } from "svelte/store";
import { addOrUpdateNote } from "./add-or-update-note.svelte";
import ImageOcclusionPage from "./ImageOcclusionPage.svelte";
import type { IOMode } from "./lib";
import { hideAllGuessOne } from "./store";
import { occlusionMode } from "./store";
globalThis.anki = globalThis.anki || {};
@ -31,7 +31,7 @@ export async function setupImageOcclusion(mode: IOMode, target = document.body):
await i18n;
async function addNote(): Promise<void> {
addOrUpdateNote(mode, get(hideAllGuessOne));
addOrUpdateNote(mode, get(occlusionMode));
}
// for adding note from mobile devices

View file

@ -9,8 +9,8 @@ import { get } from "svelte/store";
import { optimumCssSizeForCanvas } from "./canvas-scale";
import {
hideAllGuessOne,
notesDataStore,
occlusionMode,
opacityStateStore,
saveNeededStore,
tagsWritable,
@ -75,7 +75,7 @@ export const setupMaskEditorForEdit = async (
const clozeNote = clozeNoteResponse.value.value;
const canvas = initCanvas();
hideAllGuessOne.set(clozeNote.occludeInactive);
occlusionMode.set(clozeNote.occlusionMode);
// get image width and height
const image = document.getElementById("image") as HTMLImageElement;

View file

@ -7,6 +7,7 @@ import { ModuleName, setupI18n } from "@tslib/i18n";
import { optimumPixelSizeForCanvas } from "./canvas-scale";
import { Shape } from "./shapes";
import { Ellipse, extractShapesFromRenderedClozes, Polygon, Rectangle, Text } from "./shapes";
import { OcclusionMode } from "./store";
import { SHAPE_MASK_COLOR, TEXT_BACKGROUND_COLOR, TEXT_FONT_FAMILY, TEXT_PADDING } from "./tools/lib";
import type { Size } from "./types";
@ -168,10 +169,18 @@ async function setupImageOcclusionInner(setupOptions?: SetupImageOcclusionOption
// setup button for toggle image occlusion
const button = document.getElementById("toggle");
if (button) {
if (document.querySelector("[data-occludeinactive=\"1\"]")) {
button.addEventListener("click", () => toggleMasks(setupOptions));
} else {
const hasHideAllMode = document.querySelector(`[data-occludeinactive="${OcclusionMode.HideAll}"]`);
const hasHideAllButOneMode = document.querySelector(`[data-occludeinactive="${OcclusionMode.HideAllButOne}"]`);
const hasAnyToggleableMode = hasHideAllMode || hasHideAllButOneMode;
const isBackSide = document.querySelectorAll(".cloze-highlight").length > 0;
// Hide button if:
// 1. No shapes with Hide All or Hide All But One modes, OR
// 2. Hide All But One mode on the back side (no shapes to toggle)
if (!hasAnyToggleableMode || (hasHideAllButOneMode && isBackSide)) {
button.style.display = "none";
} else {
button.addEventListener("click", () => toggleMasks(setupOptions));
}
}
@ -202,35 +211,55 @@ function drawShapes(
properties = processed.properties;
}
for (const shape of activeShapes) {
drawShape({
context,
size,
shape,
fill: properties.activeShapeColor,
stroke: properties.activeBorder.color,
strokeWidth: properties.activeBorder.width,
});
}
for (const shape of inactiveShapes.filter((s) => s.occludeInactive)) {
drawShape({
context,
size,
shape,
fill: shape.fill !== SHAPE_MASK_COLOR ? shape.fill : properties.inActiveShapeColor,
stroke: properties.inActiveBorder.color,
strokeWidth: properties.inActiveBorder.width,
});
}
for (const shape of highlightShapes) {
drawShape({
context,
size,
shape,
fill: properties.highlightShapeColor,
stroke: properties.highlightShapeBorder.color,
strokeWidth: properties.highlightShapeBorder.width,
});
// Determine occlusion mode from the first shape
const occlusionMode = activeShapes[0]?.occlusionMode ?? inactiveShapes[0]?.occlusionMode ?? OcclusionMode.HideOne;
// HideOne: Draw active only (front), reveal answer with highlight (back)
// HideAll: Draw both active and inactive (front & back)
// HideAllButOne: Draw inactive only (front), draw nothing (back)
// Check if we're on the back side (highlightShapes only exist on back)
const isBackSide = highlightShapes.length > 0;
// For HideAllButOne on the back side, draw nothing (show full unoccluded image)
if (occlusionMode === OcclusionMode.HideAllButOne && isBackSide) {
// Don't draw any shapes on the back for "Hide All But One" mode
} else {
// Normal drawing logic for all other cases
if (occlusionMode !== OcclusionMode.HideAllButOne) {
for (const shape of activeShapes) {
drawShape({
context,
size,
shape,
fill: properties.activeShapeColor,
stroke: properties.activeBorder.color,
strokeWidth: properties.activeBorder.width,
});
}
}
if (occlusionMode === OcclusionMode.HideAll || occlusionMode === OcclusionMode.HideAllButOne) {
for (const shape of inactiveShapes) {
drawShape({
context,
size,
shape,
fill: shape.fill !== SHAPE_MASK_COLOR ? shape.fill : properties.inActiveShapeColor,
stroke: properties.inActiveBorder.color,
strokeWidth: properties.inActiveBorder.width,
});
}
}
for (const shape of highlightShapes) {
drawShape({
context,
size,
shape,
fill: properties.highlightShapeColor,
stroke: properties.highlightShapeBorder.color,
strokeWidth: properties.highlightShapeBorder.width,
});
}
}
onDidDrawShapes?.({

View file

@ -20,23 +20,23 @@ export class Shape {
top: number;
angle?: number; // polygons don't use it
fill: string;
/** Whether occlusions from other cloze numbers should be shown on the
* question side. Used only in reviewer code.
/** Occlusion mode: 0=HideOne, 1=HideAll, 2=HideAllButOne.
* Used only in reviewer code.
*/
occludeInactive?: boolean;
occlusionMode?: number;
/* Cloze ordinal */
ordinal: number | undefined;
id: string | undefined;
constructor(
{ left = 0, top = 0, angle = 0, fill = SHAPE_MASK_COLOR, occludeInactive, ordinal = undefined }:
{ left = 0, top = 0, angle = 0, fill = SHAPE_MASK_COLOR, occlusionMode, ordinal = undefined }:
ConstructorParams<Shape> = {},
) {
this.left = left;
this.top = top;
this.angle = angle;
this.fill = fill;
this.occludeInactive = occludeInactive;
this.occlusionMode = occlusionMode;
this.ordinal = ordinal;
}

View file

@ -64,7 +64,7 @@ function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
return null;
}
const props = {
occludeInactive: cloze.dataset.occludeinactive === "1",
occlusionMode: cloze.dataset.occludeinactive ? parseInt(cloze.dataset.occludeinactive) : undefined,
ordinal: parseInt(cloze.dataset.ordinal!),
left: cloze.dataset.left,
top: cloze.dataset.top,

View file

@ -4,6 +4,7 @@
import { fabric } from "fabric";
import { cloneDeep } from "lodash-es";
import { OcclusionMode } from "../store";
import { getBoundingBoxSize } from "../tools/lib";
import type { Size } from "../types";
import type { Shape, ShapeOrShapes } from "./base";
@ -12,7 +13,7 @@ import { Polygon } from "./polygon";
import { Rectangle } from "./rectangle";
import { Text } from "./text";
export function exportShapesToClozeDeletions(occludeInactive: boolean): {
export function exportShapesToClozeDeletions(mode: OcclusionMode): {
clozes: string;
noteCount: number;
} {
@ -76,7 +77,7 @@ export function exportShapesToClozeDeletions(occludeInactive: boolean): {
clozes += shapeOrShapesToCloze(
shapeOrShapes,
ordinal,
occludeInactive,
mode,
);
if (!(shapeOrShapes instanceof Text)) {
@ -179,7 +180,7 @@ function fabricObjectToBaseShapeOrShapes(
function shapeOrShapesToCloze(
shapeOrShapes: ShapeOrShapes,
ordinal: number,
occludeInactive: boolean,
mode: OcclusionMode,
): string {
let text = "";
function addKeyValue(key: string, value: string) {
@ -190,7 +191,7 @@ function shapeOrShapesToCloze(
let type: string;
if (Array.isArray(shapeOrShapes)) {
return shapeOrShapes
.map((shape) => shapeOrShapesToCloze(shape, ordinal, occludeInactive))
.map((shape) => shapeOrShapesToCloze(shape, ordinal, mode))
.join("");
} else if (shapeOrShapes instanceof Rectangle) {
type = "rect";
@ -207,8 +208,8 @@ function shapeOrShapesToCloze(
for (const [key, value] of Object.entries(shapeOrShapes.toDataForCloze())) {
addKeyValue(key, value);
}
if (occludeInactive) {
addKeyValue("oi", "1");
if (mode !== OcclusionMode.HideOne) {
addKeyValue("oi", mode.toString());
}
text = `{{c${ordinal}::image-occlusion:${type}${text}}}<br>`;

View file

@ -3,14 +3,20 @@
import { writable } from "svelte/store";
export enum OcclusionMode {
HideOne = 0,
HideAll = 1,
HideAllButOne = 2,
}
// it stores note's data for generate.ts, when function generate() is called it will be used to generate the note
export const notesDataStore = writable({ id: "", title: "", divValue: "", textareaValue: "" }[0]);
// it stores the tags for the note in note editor
export const tagsWritable = writable([""]);
// it stores the visibility of mask editor
export const ioMaskEditorVisible = writable(true);
// it store hide all or hide one mode
export const hideAllGuessOne = writable(true);
// it stores the occlusion mode (hide one, hide all, or hide all reveal one)
export const occlusionMode = writable(OcclusionMode.HideAll);
// ioImageLoadedStore is used to store the image loaded event
export const ioImageLoadedStore = writable(false);
// store opacity state of objects in canvas