image occlusion button in note editor (#2485)

* setup mask editor in note editor
- add image on mask button click (only one time)
- show hide add button for io on notetype change
- hide field in io notetype
- icon for toggle
and replace image

* add update io notes

* Tidy up i/o notetype check and fix error

- Make it a method on editor
- Use .get(), because the setting doesn't exist on older notetypes
- Pass the bool value into the ts code, instead of the enum

* reset io page after adding

* remove adjust function & add target for mask editor

* handle browse mode & merged sidetoolbar and toptoolbar to toolbar

* fix: shape, button click in browse, dropdown menu

* add arrow to add button

* store for handling visiblity of maskeditor
- remove update  button in edit mode, implement autoupdate

* update var name

* simplify store
This commit is contained in:
Mani 2023-07-27 20:45:49 +08:00 committed by GitHub
parent 0a418e0612
commit 135de7f9ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 520 additions and 240 deletions

View file

@ -51,3 +51,5 @@ notetypes-error-generating-cloze = An error occurred when generating an image oc
notetypes-error-getting-imagecloze = An error occurred while fetching 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? notetypes-error-loading-image-occlusion = Error loading image occlusion. Is your Anki version up to date?
notetype-error-no-image-to-show = No image to show. notetype-error-no-image-to-show = No image to show.
notetypes-no-occlusion-created = You must make at least one occlusion.
notetypes-io-select-image = Select Image

View file

@ -466,6 +466,10 @@ class Collection(DeprecatedNamesMixin):
def get_image_for_occlusion(self, path: str | None) -> GetImageForOcclusionResponse: def get_image_for_occlusion(self, path: str | None) -> GetImageForOcclusionResponse:
return self._backend.get_image_for_occlusion(path=path) return self._backend.get_image_for_occlusion(path=path)
def add_image_occlusion_notetype(self) -> None:
"Add notetype if missing."
self._backend.add_image_occlusion_notetype()
def add_image_occlusion_note( def add_image_occlusion_note(
self, self,
notetype_id: int, notetype_id: int,

View file

@ -27,6 +27,7 @@ NotetypeNameIdUseCount = notetypes_pb2.NotetypeNameIdUseCount
NotetypeNames = notetypes_pb2.NotetypeNames NotetypeNames = notetypes_pb2.NotetypeNames
ChangeNotetypeInfo = notetypes_pb2.ChangeNotetypeInfo ChangeNotetypeInfo = notetypes_pb2.ChangeNotetypeInfo
ChangeNotetypeRequest = notetypes_pb2.ChangeNotetypeRequest ChangeNotetypeRequest = notetypes_pb2.ChangeNotetypeRequest
StockNotetype = notetypes_pb2.StockNotetype
# legacy types # legacy types
NotetypeDict = dict[str, Any] NotetypeDict = dict[str, Any]

View file

@ -48,9 +48,10 @@ class AddCards(QMainWindow):
self.setMinimumWidth(400) self.setMinimumWidth(400)
self.setup_choosers() self.setup_choosers()
self.setupEditor() self.setupEditor()
self.setupButtons()
add_close_shortcut(self) add_close_shortcut(self)
self._load_new_note() self._load_new_note()
self.setupButtons()
self.col.add_image_occlusion_notetype()
self.history: list[NoteId] = [] self.history: list[NoteId] = []
self._last_added_note: Optional[Note] = None self._last_added_note: Optional[Note] = None
gui_hooks.operation_did_execute.append(self.on_operation_did_execute) gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
@ -112,6 +113,12 @@ class AddCards(QMainWindow):
self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self) self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self)
qconnect(self.compat_add_shorcut.activated, self.addButton.click) qconnect(self.compat_add_shorcut.activated, self.addButton.click)
self.addButton.setToolTip(shortcut(tr.adding_add_shortcut_ctrlandenter())) self.addButton.setToolTip(shortcut(tr.adding_add_shortcut_ctrlandenter()))
# add io button
self.io_add_button = bb.addButton(f"{tr.actions_add()} {downArrow()}", ar)
qconnect(self.io_add_button.clicked, self.onAddIo)
self.io_add_button.setShortcut(QKeySequence("Ctrl+Shift+I"))
# close # close
self.closeButton = QPushButton(tr.actions_close()) self.closeButton = QPushButton(tr.actions_close())
self.closeButton.setAutoDefault(False) self.closeButton.setAutoDefault(False)
@ -133,6 +140,17 @@ class AddCards(QMainWindow):
b.setEnabled(False) b.setEnabled(False)
self.historyButton = b self.historyButton = b
# hide io buttons for note type other than image occlusion
self.show_hide_add_buttons()
def show_hide_add_buttons(self) -> None:
if self.editor.current_notetype_is_image_occlusion():
self.addButton.setVisible(False)
self.io_add_button.setVisible(True)
else:
self.addButton.setVisible(True)
self.io_add_button.setVisible(False)
def setAndFocusNote(self, note: Note) -> None: def setAndFocusNote(self, note: Note) -> None:
self.editor.set_note(note, focusTo=0) self.editor.set_note(note, focusTo=0)
@ -192,6 +210,9 @@ class AddCards(QMainWindow):
self, old_note.note_type(), new_note.note_type() self, old_note.note_type(), new_note.note_type()
) )
# update buttons for image occlusion on note type change
self.show_hide_add_buttons()
def _load_new_note(self, sticky_fields_from: Optional[Note] = None) -> None: def _load_new_note(self, sticky_fields_from: Optional[Note] = None) -> None:
note = self._new_note() note = self._new_note()
if old_note := sticky_fields_from: if old_note := sticky_fields_from:
@ -283,7 +304,10 @@ class AddCards(QMainWindow):
# no problem, duplicate, and confirmed cloze cases # no problem, duplicate, and confirmed cloze cases
problem = None problem = None
if result == NoteFieldsCheckResult.EMPTY: if result == NoteFieldsCheckResult.EMPTY:
problem = tr.adding_the_first_field_is_empty() if self.editor.current_notetype_is_image_occlusion():
problem = tr.notetypes_no_occlusion_created()
else:
problem = tr.adding_the_first_field_is_empty()
elif result == NoteFieldsCheckResult.MISSING_CLOZE: elif result == NoteFieldsCheckResult.MISSING_CLOZE:
if not askUser(tr.adding_you_have_a_cloze_deletion_note()): if not askUser(tr.adding_you_have_a_cloze_deletion_note()):
return False return False
@ -348,6 +372,24 @@ class AddCards(QMainWindow):
self.ifCanClose(doClose) self.ifCanClose(doClose)
def onAddIo(self) -> None:
m = QMenu(self)
a = m.addAction(tr.notetypes_hide_all_guess_one())
qconnect(a.triggered, self.add_io_hide_all_note)
a = m.addAction(tr.notetypes_hide_one_guess_one())
qconnect(a.triggered, self.add_io_hide_one_note)
m.popup(QCursor.pos())
def add_io_hide_all_note(self) -> None:
self.editor.web.eval("setOcclusionField(true)")
self.add_current_note()
self.editor.web.eval("resetIOImageLoaded()")
def add_io_hide_one_note(self) -> None:
self.editor.web.eval("setOcclusionField(false)")
self.add_current_note()
self.editor.web.eval("resetIOImageLoaded()")
# legacy aliases # legacy aliases
@property @property

View file

@ -21,9 +21,6 @@ class EditCurrent(QDialog):
disable_help_button(self) disable_help_button(self)
self.setMinimumHeight(400) self.setMinimumHeight(400)
self.setMinimumWidth(250) self.setMinimumWidth(250)
self.form.buttonBox.button(QDialogButtonBox.StandardButton.Close).setShortcut(
QKeySequence("Ctrl+Return")
)
self.editor = aqt.editor.Editor( self.editor = aqt.editor.Editor(
self.mw, self.mw,
self.form.fieldsArea, self.form.fieldsArea,
@ -33,6 +30,9 @@ class EditCurrent(QDialog):
self.editor.card = self.mw.reviewer.card self.editor.card = self.mw.reviewer.card
self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0) self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0)
restoreGeom(self, "editcurrent") restoreGeom(self, "editcurrent")
self.form.buttonBox.button(QDialogButtonBox.StandardButton.Close).setShortcut(
QKeySequence("Ctrl+Return")
)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute) gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
self.show() self.show()

View file

@ -31,6 +31,7 @@ from anki.collection import Config, SearchNode
from anki.consts import MODEL_CLOZE from anki.consts import MODEL_CLOZE
from anki.hooks import runFilter from anki.hooks import runFilter
from anki.httpclient import HttpClient from anki.httpclient import HttpClient
from anki.models import StockNotetype
from anki.notes import Note, NoteFieldsCheckResult from anki.notes import Note, NoteFieldsCheckResult
from anki.utils import checksum, is_lin, is_win, namedtmp from anki.utils import checksum, is_lin, is_win, namedtmp
from aqt import AnkiQt, colors, gui_hooks from aqt import AnkiQt, colors, gui_hooks
@ -549,17 +550,33 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
setShrinkImages({json.dumps(self.mw.col.get_config("shrinkEditorImages", True))}); setShrinkImages({json.dumps(self.mw.col.get_config("shrinkEditorImages", True))});
setCloseHTMLTags({json.dumps(self.mw.col.get_config("closeHTMLTags", True))}); setCloseHTMLTags({json.dumps(self.mw.col.get_config("closeHTMLTags", True))});
triggerChanges(); triggerChanges();
setIsImageOcclusion({json.dumps(self.current_notetype_is_image_occlusion())});
setIsEditMode({json.dumps(self.editorMode != EditorMode.ADD_CARDS)});
""" """
if self.addMode: if self.addMode:
sticky = [field["sticky"] for field in self.note.note_type()["flds"]] sticky = [field["sticky"] for field in self.note.note_type()["flds"]]
js += " setSticky(%s);" % json.dumps(sticky) js += " setSticky(%s);" % json.dumps(sticky)
if (
self.editorMode != EditorMode.ADD_CARDS
and self.current_notetype_is_image_occlusion()
):
options = {"kind": "edit", "noteId": self.note.id}
options = {"mode": options}
js += " setupMaskEditor(%s);" % json.dumps(options)
js = gui_hooks.editor_will_load_note(js, self.note, self) js = gui_hooks.editor_will_load_note(js, self.note, self)
self.web.evalWithCallback( self.web.evalWithCallback(
f'require("anki/ui").loaded.then(() => {{ {js} }})', oncallback f'require("anki/ui").loaded.then(() => {{ {js} }})', oncallback
) )
def current_notetype_is_image_occlusion(self) -> bool:
return bool(self.note) and (
self.note.note_type().get("originalStockKind", None)
== StockNotetype.OriginalStockKind.ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION
)
def _save_current_note(self) -> None: def _save_current_note(self) -> None:
"Call after note is updated with data from webview." "Call after note is updated with data from webview."
update_note(parent=self.widget, note=self.note).run_in_background( update_note(parent=self.widget, note=self.note).run_in_background(
@ -1175,6 +1192,34 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
def setTagsCollapsed(self, collapsed: bool) -> None: def setTagsCollapsed(self, collapsed: bool) -> None:
aqt.mw.pm.set_tags_collapsed(self.editorMode, collapsed) aqt.mw.pm.set_tags_collapsed(self.editorMode, collapsed)
def onAddImageForOcclusion(self) -> None:
"""Show a file selection screen, then get selected image path."""
extension_filter = " ".join(
f"*.{extension}" for extension in sorted(itertools.chain(pics))
)
filter = f"{tr.editing_media()} ({extension_filter})"
def accept(file: str) -> None:
try:
html = self._addMedia(file)
mode = {"kind": "add", "imagePath": file, "notetypeId": 0}
# pass both html and options
options = {"html": html, "mode": mode}
self.web.eval(f"setupMaskEditor({json.dumps(options)})")
except Exception as e:
showWarning(str(e))
return
file = getFile(
parent=self.widget,
title=tr.editing_add_media(),
cb=cast(Callable[[Any], None], accept),
filter=filter,
key="media",
)
self.parentWindow.activateWindow()
# Links from HTML # Links from HTML
###################################################################### ######################################################################
@ -1204,6 +1249,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
toggleMathjax=Editor.toggleMathjax, toggleMathjax=Editor.toggleMathjax,
toggleShrinkImages=Editor.toggleShrinkImages, toggleShrinkImages=Editor.toggleShrinkImages,
toggleCloseHTMLTags=Editor.toggleCloseHTMLTags, toggleCloseHTMLTags=Editor.toggleCloseHTMLTags,
addImageForOcclusion=Editor.onAddImageForOcclusion,
) )
@ -1452,4 +1498,14 @@ def set_cloze_button(editor: Editor) -> None:
) )
def set_image_occlusion_button(editor: Editor) -> None:
action = "show" if editor.current_notetype_is_image_occlusion() else "hide"
editor.web.eval(
'require("anki/ui").loaded.then(() =>'
f'require("anki/NoteEditor").instances[0].toolbar.toolbar.{action}("image-occlusion-button")'
"); "
)
gui_hooks.editor_did_load_note.append(set_cloze_button) gui_hooks.editor_did_load_note.append(set_cloze_button)
gui_hooks.editor_did_load_note.append(set_image_occlusion_button)

View file

@ -15,6 +15,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
plainText: boolean; plainText: boolean;
description: string; description: string;
collapsed: boolean; collapsed: boolean;
hidden: boolean;
} }
export interface EditorFieldAPI { export interface EditorFieldAPI {
@ -87,7 +88,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
onDestroy(() => api?.destroy()); onDestroy(() => api?.destroy());
</script> </script>
<div class="field-container" on:mouseenter on:mouseleave> <div class="field-container" class:hide={field.hidden} on:mouseenter on:mouseleave>
<slot name="field-label" /> <slot name="field-label" />
<Collapsible collapse={collapsed} let:collapsed={hidden}> <Collapsible collapse={collapsed} let:collapsed={hidden}>
@ -127,6 +128,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
overflow: hidden; overflow: hidden;
} }
.field-container.hide {
display: none;
}
.editor-field { .editor-field {
overflow: hidden; overflow: hidden;
/* make room for thicker focus border */ /* make room for thicker focus border */

View file

@ -237,6 +237,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return noteId; return noteId;
} }
let isImageOcclusion = false;
function setIsImageOcclusion(val: boolean) {
isImageOcclusion = val;
$ioMaskEditorVisible = val;
}
let isEditMode = false;
function setIsEditMode(val: boolean) {
isEditMode = val;
}
let cols: ("dupe" | "")[] = []; let cols: ("dupe" | "")[] = [];
export function setBackgrounds(cls: ("dupe" | "")[]): void { export function setBackgrounds(cls: ("dupe" | "")[]): void {
cols = cls; cols = cls;
@ -255,6 +266,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
fontSize: fonts[index][1], fontSize: fonts[index][1],
direction: fonts[index][2] ? "rtl" : "ltr", direction: fonts[index][2] ? "rtl" : "ltr",
collapsed: fieldsCollapsed[index], collapsed: fieldsCollapsed[index],
hidden: hideFieldInOcclusionType(index),
})) as FieldData[]; })) as FieldData[];
function saveTags({ detail }: CustomEvent): void { function saveTags({ detail }: CustomEvent): void {
@ -286,6 +298,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
function saveNow(): void { function saveNow(): void {
updateIONoteInEditMode();
closeMathjaxEditor?.(); closeMathjaxEditor?.();
$commitTagEdits(); $commitTagEdits();
saveFieldNow(); saveFieldNow();
@ -372,12 +385,68 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
import { wrapInternal } from "@tslib/wrap"; import { wrapInternal } from "@tslib/wrap";
import LabelButton from "components/LabelButton.svelte";
import Shortcut from "components/Shortcut.svelte"; import Shortcut from "components/Shortcut.svelte";
import ImageOcclusionPage from "image-occlusion/ImageOcclusionPage.svelte";
import type { IOMode } from "image-occlusion/lib";
import { exportShapesToClozeDeletions } from "image-occlusion/shapes/to-cloze";
import { ioMaskEditorVisible } from "image-occlusion/store";
import { mathjaxConfig } from "../editable/mathjax-element"; import { mathjaxConfig } from "../editable/mathjax-element";
import CollapseLabel from "./CollapseLabel.svelte"; import CollapseLabel from "./CollapseLabel.svelte";
import * as oldEditorAdapter from "./old-editor-adapter"; import * as oldEditorAdapter from "./old-editor-adapter";
let isIOImageLoaded = false;
let imageOcclusionMode: IOMode | undefined;
async function setupMaskEditor(options: { html: string; mode: IOMode }) {
imageOcclusionMode = options.mode;
if (options.mode.kind === "add") {
fieldStores[1].set(options.html);
}
isIOImageLoaded = true;
}
// update cloze deletions and set occlusion fields, it call in saveNow to update cloze deletions
function updateIONoteInEditMode() {
if (isEditMode) {
const clozeNote = get(fieldStores[0]);
if (clozeNote.includes("oi=1")) {
setOcclusionField(true);
} else {
setOcclusionField(false);
}
}
}
// reset for new occlusion in add mode
function resetIOImageLoaded() {
isIOImageLoaded = false;
globalThis.canvas.clear();
const page = document.querySelector(".image-occlusion");
if (page) {
page.remove();
}
}
globalThis.resetIOImageLoaded = resetIOImageLoaded;
function setOcclusionField(occludeInactive: boolean) {
// set fields data for occlusion and image fields for io notes type
if (isImageOcclusion) {
const occlusionsData = exportShapesToClozeDeletions(occludeInactive);
fieldStores[0].set(occlusionsData.clozes);
}
}
// hide first two fields for occlusion type, first contains occlusion data and second contains image
function hideFieldInOcclusionType(index: number) {
if (isImageOcclusion) {
if (index == 0 || index == 1) {
return true;
}
}
return false;
}
onMount(() => { onMount(() => {
function wrap(before: string, after: string): void { function wrap(before: string, after: string): void {
if (!$focusedInput || !editingInputIsRichText($focusedInput)) { if (!$focusedInput || !editingInputIsRichText($focusedInput)) {
@ -411,6 +480,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setShrinkImages, setShrinkImages,
setCloseHTMLTags, setCloseHTMLTags,
triggerChanges, triggerChanges,
setIsImageOcclusion,
setIsEditMode,
setupMaskEditor,
setOcclusionField,
...oldEditorAdapter, ...oldEditorAdapter,
}); });
@ -464,137 +537,158 @@ the AddCards dialog) should be implemented in the user of this component.
</Absolute> </Absolute>
{/if} {/if}
<Fields> {#if imageOcclusionMode}
{#each fieldsData as field, index} <div style="display: {$ioMaskEditorVisible ? 'block' : 'none'}">
{@const content = fieldStores[index]} <ImageOcclusionPage mode={imageOcclusionMode} />
</div>
{/if}
<EditorField {#if $ioMaskEditorVisible && isImageOcclusion && !isIOImageLoaded}
{field} <div id="io-select-image-div" style="padding-top: 60px; text-align: center;">
{content} <LabelButton
flipInputs={plainTextDefaults[index]} --border-left-radius="5px"
api={fields[index]} --border-right-radius="5px"
on:focusin={() => { class="io-select-image-btn"
$focusedField = fields[index]; on:click={() => bridgeCommand("addImageForOcclusion")}
setAddonButtonsDisabled(false);
bridgeCommand(`focus:${index}`);
}}
on:focusout={() => {
$focusedField = null;
setAddonButtonsDisabled(true);
bridgeCommand(
`blur:${index}:${getNoteId()}:${transformContentBeforeSave(
get(content),
)}`,
);
}}
on:mouseenter={() => {
$hoveredField = fields[index];
}}
on:mouseleave={() => {
$hoveredField = null;
}}
collapsed={fieldsCollapsed[index]}
dupe={cols[index] === "dupe"}
--description-font-size="{field.fontSize}px"
--description-content={`"${field.description}"`}
> >
<svelte:fragment slot="field-label"> {tr.notetypesIoSelectImage()}
<LabelContainer </LabelButton>
collapsed={fieldsCollapsed[index]} </div>
on:toggle={() => toggleField(index)} {/if}
--icon-align="bottom"
> {#if !$ioMaskEditorVisible}
<svelte:fragment slot="field-name"> <Fields>
<LabelName> {#each fieldsData as field, index}
{field.name} {@const content = fieldStores[index]}
</LabelName>
</svelte:fragment> <EditorField
<FieldState> {field}
{#if cols[index] === "dupe"} {content}
<DuplicateLink /> flipInputs={plainTextDefaults[index]}
{/if} api={fields[index]}
{#if plainTextDefaults[index]} on:focusin={() => {
<RichTextBadge $focusedField = fields[index];
show={!fieldsCollapsed[index] && setAddonButtonsDisabled(false);
(fields[index] === $hoveredField || bridgeCommand(`focus:${index}`);
fields[index] === $focusedField)} }}
bind:off={richTextsHidden[index]} on:focusout={() => {
on:toggle={() => toggleRichTextInput(index)} $focusedField = null;
setAddonButtonsDisabled(true);
bridgeCommand(
`blur:${index}:${getNoteId()}:${transformContentBeforeSave(
get(content),
)}`,
);
}}
on:mouseenter={() => {
$hoveredField = fields[index];
}}
on:mouseleave={() => {
$hoveredField = null;
}}
collapsed={fieldsCollapsed[index]}
dupe={cols[index] === "dupe"}
--description-font-size="{field.fontSize}px"
--description-content={`"${field.description}"`}
>
<svelte:fragment slot="field-label">
<LabelContainer
collapsed={fieldsCollapsed[index]}
on:toggle={() => toggleField(index)}
--icon-align="bottom"
>
<svelte:fragment slot="field-name">
<LabelName>
{field.name}
</LabelName>
</svelte:fragment>
<FieldState>
{#if cols[index] === "dupe"}
<DuplicateLink />
{/if}
{#if plainTextDefaults[index]}
<RichTextBadge
show={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField ||
fields[index] === $focusedField)}
bind:off={richTextsHidden[index]}
on:toggle={() => toggleRichTextInput(index)}
/>
{:else}
<PlainTextBadge
show={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField ||
fields[index] === $focusedField)}
bind:off={plainTextsHidden[index]}
on:toggle={() => togglePlainTextInput(index)}
/>
{/if}
<slot
name="field-state"
{field}
{index}
show={fields[index] === $hoveredField ||
fields[index] === $focusedField}
/> />
{:else} </FieldState>
<PlainTextBadge </LabelContainer>
show={!fieldsCollapsed[index] && </svelte:fragment>
(fields[index] === $hoveredField || <svelte:fragment slot="rich-text-input">
fields[index] === $focusedField)} <Collapsible
bind:off={plainTextsHidden[index]} collapse={richTextsHidden[index]}
on:toggle={() => togglePlainTextInput(index)} let:collapsed={hidden}
/> toggleDisplay
{/if} >
<slot <RichTextInput
name="field-state" {hidden}
{field} on:focusout={() => {
{index} saveFieldNow();
show={fields[index] === $hoveredField || $focusedInput = null;
fields[index] === $focusedField} }}
bind:this={richTextInputs[index]}
/> />
</FieldState> </Collapsible>
</LabelContainer> </svelte:fragment>
</svelte:fragment> <svelte:fragment slot="plain-text-input">
<svelte:fragment slot="rich-text-input"> <Collapsible
<Collapsible collapse={plainTextsHidden[index]}
collapse={richTextsHidden[index]} let:collapsed={hidden}
let:collapsed={hidden} toggleDisplay
toggleDisplay >
> <PlainTextInput
<RichTextInput {hidden}
{hidden} on:focusout={() => {
on:focusout={() => { saveFieldNow();
saveFieldNow(); $focusedInput = null;
$focusedInput = null; }}
}} bind:this={plainTextInputs[index]}
bind:this={richTextInputs[index]} />
/> </Collapsible>
</Collapsible> </svelte:fragment>
</svelte:fragment> </EditorField>
<svelte:fragment slot="plain-text-input"> {/each}
<Collapsible
collapse={plainTextsHidden[index]}
let:collapsed={hidden}
toggleDisplay
>
<PlainTextInput
{hidden}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;
}}
bind:this={plainTextInputs[index]}
/>
</Collapsible>
</svelte:fragment>
</EditorField>
{/each}
<MathjaxOverlay /> <MathjaxOverlay />
<ImageOverlay maxWidth={250} maxHeight={125} /> <ImageOverlay maxWidth={250} maxHeight={125} />
</Fields> </Fields>
<Shortcut <Shortcut
keyCombination="Control+Shift+T" keyCombination="Control+Shift+T"
on:action={() => { on:action={() => {
updateTagsCollapsed(false); updateTagsCollapsed(false);
}} }}
/> />
<CollapseLabel <CollapseLabel
collapsed={$tagsCollapsed} collapsed={$tagsCollapsed}
tooltip={$tagsCollapsed ? tr.editingExpand() : tr.editingCollapse()} tooltip={$tagsCollapsed ? tr.editingExpand() : tr.editingCollapse()}
on:toggle={() => updateTagsCollapsed(!$tagsCollapsed)} on:toggle={() => updateTagsCollapsed(!$tagsCollapsed)}
> >
{@html `${tagAmount > 0 ? tagAmount : ""} ${tr.editingTags()}`} {@html `${tagAmount > 0 ? tagAmount : ""} ${tr.editingTags()}`}
</CollapseLabel> </CollapseLabel>
<Collapsible toggleDisplay collapse={$tagsCollapsed}> <Collapsible toggleDisplay collapse={$tagsCollapsed}>
<TagEditor {tags} on:tagsupdate={saveTags} /> <TagEditor {tags} on:tagsupdate={saveTags} />
</Collapsible> </Collapsible>
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">
@ -603,4 +697,32 @@ the AddCards dialog) should be implemented in the user of this component.
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
} }
:global(.image-occlusion) {
position: fixed;
}
:global(.image-occlusion .tab-buttons) {
display: none !important;
}
:global(.image-occlusion .top-tool-bar-container) {
margin-left: 28px !important;
}
:global(.top-tool-bar-container .icon-button) {
height: 36px !important;
}
:global(.image-occlusion .tool-bar-container) {
top: unset !important;
margin-top: 2px !important;
}
:global(.io-select-image-btn) {
margin: auto;
padding: 0px 8px 0px 8px !important;
}
:global(.image-occlusion .sticky-footer) {
display: none;
}
</style> </style>

View file

@ -38,6 +38,8 @@ export const editorModules = [
ModuleName.KEYBOARD, ModuleName.KEYBOARD,
ModuleName.ACTIONS, ModuleName.ACTIONS,
ModuleName.BROWSING, ModuleName.BROWSING,
ModuleName.NOTETYPES,
ModuleName.IMPORTING,
]; ];
export const components = { export const components = {

View file

@ -55,6 +55,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import DynamicallySlottable from "../../components/DynamicallySlottable.svelte"; import DynamicallySlottable from "../../components/DynamicallySlottable.svelte";
import Item from "../../components/Item.svelte"; import Item from "../../components/Item.svelte";
import BlockButtons from "./BlockButtons.svelte"; import BlockButtons from "./BlockButtons.svelte";
import ImageOcclusionButton from "./ImageOcclusionButton.svelte";
import InlineButtons from "./InlineButtons.svelte"; import InlineButtons from "./InlineButtons.svelte";
import NotetypeButtons from "./NotetypeButtons.svelte"; import NotetypeButtons from "./NotetypeButtons.svelte";
import OptionsButtons from "./OptionsButtons.svelte"; import OptionsButtons from "./OptionsButtons.svelte";
@ -120,6 +121,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<Item id="cloze"> <Item id="cloze">
<RichTextClozeButtons /> <RichTextClozeButtons />
</Item> </Item>
<Item id="image-occlusion-button">
<ImageOcclusionButton />
</Item>
</DynamicallySlottable> </DynamicallySlottable>
</ButtonToolbar> </ButtonToolbar>
</div> </div>

View file

@ -0,0 +1,48 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ButtonGroup from "components/ButtonGroup.svelte";
import DynamicallySlottable from "components/DynamicallySlottable.svelte";
import IconButton from "components/IconButton.svelte";
import { ioMaskEditorVisible } from "image-occlusion/store";
import ButtonGroupItem, {
createProps,
setSlotHostContext,
updatePropsList,
} from "../../components/ButtonGroupItem.svelte";
import { mdiViewDashboard } from "./icons";
export let api = {};
</script>
<ButtonGroup>
<DynamicallySlottable
slotHost={ButtonGroupItem}
{createProps}
{updatePropsList}
{setSlotHostContext}
{api}
>
<ButtonGroupItem>
<IconButton
id="io-mask-btn"
class={$ioMaskEditorVisible ? "active-io-btn" : ""}
on:click={() => {
$ioMaskEditorVisible = !$ioMaskEditorVisible;
}}
>
{@html mdiViewDashboard}
</IconButton>
</ButtonGroupItem>
</DynamicallySlottable>
</ButtonGroup>
<style>
:global(.active-io-btn) {
background: var(--button-primary-bg) !important;
color: white !important;
}
</style>

View file

@ -11,6 +11,8 @@ export { default as subscriptIcon } from "@mdi/svg/svg/format-subscript.svg";
export { default as superscriptIcon } from "@mdi/svg/svg/format-superscript.svg"; export { default as superscriptIcon } from "@mdi/svg/svg/format-superscript.svg";
export { default as functionIcon } from "@mdi/svg/svg/function-variant.svg"; export { default as functionIcon } from "@mdi/svg/svg/function-variant.svg";
export { default as paperclipIcon } from "@mdi/svg/svg/paperclip.svg"; export { default as paperclipIcon } from "@mdi/svg/svg/paperclip.svg";
export { default as mdiRefresh } from "@mdi/svg/svg/refresh.svg";
export { default as mdiViewDashboard } from "@mdi/svg/svg/view-dashboard.svg";
export { default as eraserIcon } from "bootstrap-icons/icons/eraser.svg"; export { default as eraserIcon } from "bootstrap-icons/icons/eraser.svg";
export { default as justifyFullIcon } from "bootstrap-icons/icons/justify.svg"; export { default as justifyFullIcon } from "bootstrap-icons/icons/justify.svg";
export { default as olIcon } from "bootstrap-icons/icons/list-ol.svg"; export { default as olIcon } from "bootstrap-icons/icons/list-ol.svg";

View file

@ -15,6 +15,7 @@
{ "path": "../sveltelib" }, { "path": "../sveltelib" },
{ "path": "../editable" }, { "path": "../editable" },
{ "path": "../html-filter" }, { "path": "../html-filter" },
{ "path": "../tag-editor" } { "path": "../tag-editor" },
{ "path": "../image-occlusion" }
] ]
} }

View file

@ -8,10 +8,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { IOMode } from "./lib"; import type { IOMode } from "./lib";
import { setupMaskEditor, setupMaskEditorForEdit } from "./mask-editor"; import { setupMaskEditor, setupMaskEditorForEdit } from "./mask-editor";
import SideToolbar from "./SideToolbar.svelte"; import Toolbar from "./Toolbar.svelte";
export let mode: IOMode; export let mode: IOMode;
const iconSize = 80;
let instance: PanZoom; let instance: PanZoom;
let innerWidth = 0; let innerWidth = 0;
const startingTool = mode.kind === "add" ? "draw-rectangle" : "cursor"; const startingTool = mode.kind === "add" ? "draw-rectangle" : "cursor";
@ -39,7 +39,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
</script> </script>
<SideToolbar {instance} {canvas} activeTool={startingTool} /> <Toolbar {canvas} {instance} {iconSize} activeTool={startingTool} />
<div class="editor-main" bind:clientWidth={innerWidth}> <div class="editor-main" bind:clientWidth={innerWidth}>
<div class="editor-container" use:init> <div class="editor-container" use:init>
<!-- svelte-ignore a11y-missing-attribute --> <!-- svelte-ignore a11y-missing-attribute -->

View file

@ -85,7 +85,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
.note-toolbar { .note-toolbar {
margin-left: 98px; margin-left: 106px;
margin-top: 2px; margin-top: 2px;
display: flex; display: flex;
overflow-x: auto; overflow-x: auto;

View file

@ -1,99 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import IconButton from "../components/IconButton.svelte";
import { drawEllipse, drawPolygon, drawRectangle } from "./tools/index";
import { enableSelectable, stopDraw } from "./tools/lib";
import { tools } from "./tools/tool-buttons";
import TopToolbar from "./TopToolbar.svelte";
export let instance;
export let canvas;
const iconSize = 80;
export let activeTool = "cursor";
// handle tool changes after initialization
$: if (instance && canvas) {
disableFunctions();
enableSelectable(canvas, true);
switch (activeTool) {
case "magnify":
enableSelectable(canvas, false);
instance.resume();
break;
case "draw-rectangle":
drawRectangle(canvas);
break;
case "draw-ellipse":
drawEllipse(canvas);
break;
case "draw-polygon":
drawPolygon(canvas, instance);
break;
default:
break;
}
}
const disableFunctions = () => {
instance.pause();
stopDraw(canvas);
canvas.selectionColor = "rgba(100, 100, 255, 0.3)";
};
</script>
<TopToolbar {canvas} {instance} {iconSize} />
<div class="tool-bar-container">
{#each tools as tool}
<IconButton
class="tool-icon-button {activeTool == tool.id ? 'active-tool' : ''}"
{iconSize}
active={activeTool === tool.id}
on:click={() => {
activeTool = tool.id;
}}
>
{@html tool.icon}
</IconButton>
{/each}
</div>
<style>
.tool-bar-container {
position: fixed;
top: 42px;
left: 2px;
height: 100%;
border-right: 1px solid var(--border);
overflow-y: auto;
width: 32px;
z-index: 99;
background: var(--canvas-elevated);
padding-bottom: 100px;
}
:global(.tool-icon-button) {
border: unset;
display: block;
width: 32px;
height: 32px;
margin: unset;
padding: 6px !important;
}
:global(.active-tool) {
color: white !important;
background: var(--button-primary-bg) !important;
}
::-webkit-scrollbar {
width: 0.2em !important;
height: 0.2em !important;
}
</style>

View file

@ -5,18 +5,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script> <script>
import IconButton from "../components/IconButton.svelte"; import IconButton from "../components/IconButton.svelte";
import { mdiEye, mdiFormatAlignCenter } from "./icons"; import { mdiEye, mdiFormatAlignCenter } from "./icons";
import { drawEllipse, drawPolygon, drawRectangle } from "./tools/index";
import { makeMaskTransparent } from "./tools/lib"; import { makeMaskTransparent } from "./tools/lib";
import { enableSelectable, stopDraw } from "./tools/lib";
import { import {
alignTools, alignTools,
deleteDuplicateTools, deleteDuplicateTools,
groupUngroupTools, groupUngroupTools,
zoomTools, zoomTools,
} from "./tools/more-tools"; } from "./tools/more-tools";
import { tools } from "./tools/tool-buttons";
import { undoRedoTools } from "./tools/tool-undo-redo"; import { undoRedoTools } from "./tools/tool-undo-redo";
export let canvas; export let canvas;
export let instance; export let instance;
export let iconSize; export let iconSize;
export let activeTool = "cursor";
let showAlignTools = false; let showAlignTools = false;
let leftPos = 82; let leftPos = 82;
let maksOpacity = false; let maksOpacity = false;
@ -27,8 +31,53 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
showAlignTools = false; showAlignTools = false;
} }
}); });
// handle tool changes after initialization
$: if (instance && canvas) {
disableFunctions();
enableSelectable(canvas, true);
switch (activeTool) {
case "magnify":
enableSelectable(canvas, false);
instance.resume();
break;
case "draw-rectangle":
drawRectangle(canvas);
break;
case "draw-ellipse":
drawEllipse(canvas);
break;
case "draw-polygon":
drawPolygon(canvas, instance);
break;
default:
break;
}
}
const disableFunctions = () => {
instance.pause();
stopDraw(canvas);
canvas.selectionColor = "rgba(100, 100, 255, 0.3)";
};
</script> </script>
<div class="tool-bar-container">
{#each tools as tool}
<IconButton
class="tool-icon-button {activeTool == tool.id ? 'active-tool' : ''}"
{iconSize}
active={activeTool === tool.id}
on:click={() => {
activeTool = tool.id;
}}
>
{@html tool.icon}
</IconButton>
{/each}
</div>
<div class="top-tool-bar-container"> <div class="top-tool-bar-container">
<!-- undo & redo tools --> <!-- undo & redo tools -->
<div class="undo-redo-button"> <div class="undo-redo-button">
@ -141,7 +190,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
display: flex; display: flex;
overflow-y: scroll; overflow-y: scroll;
z-index: 99; z-index: 99;
margin-left: 98px; margin-left: 106px;
margin-top: 2px; margin-top: 2px;
} }
@ -171,6 +220,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
height: 32px; height: 32px;
margin: unset; margin: unset;
padding: 6px !important; padding: 6px !important;
font-size: 16px !important;
} }
.dropdown-content { .dropdown-content {
@ -189,4 +239,31 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
width: 0.1em !important; width: 0.1em !important;
height: 0.1em !important; height: 0.1em !important;
} }
.tool-bar-container {
position: fixed;
top: 42px;
left: 2px;
height: 100%;
border-right: 1px solid var(--border);
overflow-y: auto;
width: 32px;
z-index: 99;
background: var(--canvas-elevated);
padding-bottom: 100px;
}
:global(.tool-icon-button) {
border: unset;
display: block;
width: 32px;
height: 32px;
margin: unset;
padding: 6px !important;
}
:global(.active-tool) {
color: white !important;
background: var(--button-primary-bg) !important;
}
</style> </style>

View file

@ -20,12 +20,12 @@ const i18n = setupI18n({
], ],
}); });
export async function setupImageOcclusion(mode: IOMode): Promise<ImageOcclusionPage> { export async function setupImageOcclusion(mode: IOMode, target = document.body): Promise<ImageOcclusionPage> {
checkNightMode(); checkNightMode();
await i18n; await i18n;
return new ImageOcclusionPage({ return new ImageOcclusionPage({
target: document.body, target: target,
props: { props: {
mode, mode,
}, },

View file

@ -54,6 +54,7 @@ export const setupMaskEditorForEdit = async (noteId: number, instance: PanZoom):
// get image width and height // get image width and height
const image = document.getElementById("image") as HTMLImageElement; const image = document.getElementById("image") as HTMLImageElement;
image.style.visibility = "hidden";
image.src = getImageData(clozeNote.imageData!); image.src = getImageData(clozeNote.imageData!);
image.onload = function() { image.onload = function() {
const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize()); const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize());
@ -66,6 +67,9 @@ export const setupMaskEditorForEdit = async (noteId: number, instance: PanZoom):
addShapesToCanvasFromCloze(canvas, clozeNote.occlusions); addShapesToCanvasFromCloze(canvas, clozeNote.occlusions);
enableSelectable(canvas, true); enableSelectable(canvas, true);
addClozeNotesToTextEditor(clozeNote.header, clozeNote.backExtra, clozeNote.tags); addClozeNotesToTextEditor(clozeNote.header, clozeNote.backExtra, clozeNote.tags);
window.requestAnimationFrame(() => {
image.style.visibility = "visible";
});
}; };
return canvas; return canvas;

View file

@ -6,5 +6,8 @@
* for up to widths/heights of 10kpx. * for up to widths/heights of 10kpx.
*/ */
export function floatToDisplay(number: number): string { export function floatToDisplay(number: number): string {
if (Number.isNaN(number) || number == 0) {
return ".0000";
}
return number.toFixed(4).replace(/^0+|0+$/g, ""); return number.toFixed(4).replace(/^0+|0+$/g, "");
} }

View file

@ -108,8 +108,8 @@ function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
type ShapeType = "rect" | "ellipse" | "polygon"; type ShapeType = "rect" | "ellipse" | "polygon";
function buildShape(type: ShapeType, props: Record<string, any>): Shape { function buildShape(type: ShapeType, props: Record<string, any>): Shape {
props.left = parseFloat(props.left); props.left = parseFloat(Number.isNaN(Number(props.left)) ? ".0000" : props.left);
props.top = parseFloat(props.top); props.top = parseFloat(Number.isNaN(Number(props.top)) ? ".0000" : props.top);
switch (type) { switch (type) {
case "rect": { case "rect": {
return new Rectangle({ ...props, width: parseFloat(props.width), height: parseFloat(props.height) }); return new Rectangle({ ...props, width: parseFloat(props.width), height: parseFloat(props.height) });

View file

@ -81,6 +81,9 @@ function fabricObjectToBaseShapeOrShapes(
function shapeOrShapesToCloze(shapeOrShapes: ShapeOrShapes, index: number): string { function shapeOrShapesToCloze(shapeOrShapes: ShapeOrShapes, index: number): string {
let text = ""; let text = "";
function addKeyValue(key: string, value: string) { function addKeyValue(key: string, value: string) {
if (Number.isNaN(Number(value))) {
value = ".0000";
}
text += `:${key}=${value}`; text += `:${key}=${value}`;
} }

View file

@ -9,3 +9,5 @@ export const notesDataStore = writable({ id: "", title: "", divValue: "", textar
export const zoomResetValue = writable(1); export const zoomResetValue = writable(1);
// it stores the tags for the note in note editor // it stores the tags for the note in note editor
export const tagsWritable = writable([""]); export const tagsWritable = writable([""]);
// it stores the visibility of mask editor
export const ioMaskEditorVisible = writable(true);