mirror of
https://github.com/ankitects/anki.git
synced 2025-11-06 12:47:11 -05:00
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:
parent
dac26ce671
commit
33d1057a46
15 changed files with 133 additions and 73 deletions
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_ };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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?.({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue