Add "hide all but one" occlusion mode.

This PR adds the "hide all but one" occlusion mode. An example use case
is a note containing a collection of pairs of selection, where each
selection is the prompt for the other in its pair.

For example, given a table like

| small | big |
|-------+-----|
|   a   |  A  |
|   b   |  B  |
|   c   |  C  |

in each card, five letters are occluded, and one is shown. The user is
prompted to state the occluded symbol that is adjacent to the shown symbol.
This commit is contained in:
jariji 2025-11-03 22:32:26 +00:00
parent dac26ce671
commit 33d1057a46
15 changed files with 133 additions and 73 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

@ -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

@ -168,7 +168,7 @@ async function setupImageOcclusionInner(setupOptions?: SetupImageOcclusionOption
// setup button for toggle image occlusion
const button = document.getElementById("toggle");
if (button) {
if (document.querySelector("[data-occludeinactive=\"1\"]")) {
if (document.querySelector("[data-occludeinactive=\"1\"], [data-occludeinactive=\"2\"]")) {
button.addEventListener("click", () => toggleMasks(setupOptions));
} else {
button.style.display = "none";
@ -202,35 +202,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 ?? 0;
// Mode 0 (HideOne): Draw active only (front), reveal answer with highlight (back)
// Mode 1 (HideAll): Draw both active and inactive (front & back)
// Mode 2 (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 mode 2 on the back side, draw nothing (show full unoccluded image)
if (occlusionMode === 2 && 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 !== 2) {
for (const shape of activeShapes) {
drawShape({
context,
size,
shape,
fill: properties.activeShapeColor,
stroke: properties.activeBorder.color,
strokeWidth: properties.activeBorder.width,
});
}
}
if (occlusionMode === 1 || occlusionMode === 2) {
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