Merge branch 'main' into color-palette

This commit is contained in:
Matthias Metelka 2022-08-18 07:48:34 +02:00
parent e036edd584
commit ac4c88afdc
20 changed files with 337 additions and 205 deletions

View file

@ -58,6 +58,7 @@ editing-toggle-visual-editor = Toggle Visual Editor
editing-underline-text = Underline text editing-underline-text = Underline text
editing-unordered-list = Unordered list editing-unordered-list = Unordered list
editing-warning-cloze-deletions-will-not-work = Warning, cloze deletions will not work until you switch the type at the top to Cloze. editing-warning-cloze-deletions-will-not-work = Warning, cloze deletions will not work until you switch the type at the top to Cloze.
editing-toggle-mathjax-rendering = Toggle MathJax Rendering
## You don't need to translate these strings, as they will be replaced with different ones soon. ## You don't need to translate these strings, as they will be replaced with different ones soon.

View file

@ -10,6 +10,7 @@ fields-font = Font:
fields-new-position-1 = New position (1...{ $val }): fields-new-position-1 = New position (1...{ $val }):
fields-notes-require-at-least-one-field = Notes require at least one field. fields-notes-require-at-least-one-field = Notes require at least one field.
fields-reverse-text-direction-rtl = Reverse text direction (RTL) fields-reverse-text-direction-rtl = Reverse text direction (RTL)
fields-html-by-default = Use HTML editor by default
fields-size = Size: fields-size = Size:
fields-sort-by-this-field-in-the = Sort by this field in the browser fields-sort-by-this-field-in-the = Sort by this field in the browser
fields-that-field-name-is-already-used = That field name is already used. fields-that-field-name-is-already-used = That field name is already used.

View file

@ -72,6 +72,7 @@ message Notetype {
string font_name = 3; string font_name = 3;
uint32 font_size = 4; uint32 font_size = 4;
string description = 5; string description = 5;
bool plain_text = 6;
bytes other = 255; bytes other = 255;
} }

View file

@ -14,8 +14,8 @@
body { body {
color: var(--text-fg); color: var(--text-fg);
background: var(--window-bg); background: var(--window-bg);
margin: 1em;
transition: opacity 0.5s ease-out; transition: opacity 0.5s ease-out;
margin: 2em;
overscroll-behavior: none; overscroll-behavior: none;
} }
@ -24,11 +24,6 @@ a {
text-decoration: none; text-decoration: none;
} }
body {
margin: 2em;
overscroll-behavior: none;
}
h1 { h1 {
margin-bottom: 0.2em; margin-bottom: 0.2em;
} }

View file

@ -125,6 +125,7 @@ class Editor:
self.card: Card | None = None self.card: Card | None = None
self._init_links() self._init_links()
self.setupOuter() self.setupOuter()
self.add_webview()
self.setupWeb() self.setupWeb()
self.setupShortcuts() self.setupShortcuts()
gui_hooks.editor_did_init(self) gui_hooks.editor_did_init(self)
@ -139,11 +140,12 @@ class Editor:
self.widget.setLayout(l) self.widget.setLayout(l)
self.outerLayout = l self.outerLayout = l
def setupWeb(self) -> None: def add_webview(self) -> None:
self.web = EditorWebView(self.widget, self) self.web = EditorWebView(self.widget, self)
self.web.set_bridge_command(self.onBridgeCmd, self) self.web.set_bridge_command(self.onBridgeCmd, self)
self.outerLayout.addWidget(self.web, 1) self.outerLayout.addWidget(self.web, 1)
def setupWeb(self) -> None:
if self.editorMode == EditorMode.ADD_CARDS: if self.editorMode == EditorMode.ADD_CARDS:
file = "note_creator" file = "note_creator"
elif self.editorMode == EditorMode.BROWSER: elif self.editorMode == EditorMode.BROWSER:
@ -499,6 +501,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
] ]
flds = self.note.note_type()["flds"] flds = self.note.note_type()["flds"]
plain_texts = [fld.get("plainText", False) for fld in flds]
descriptions = [fld.get("description", "") for fld in flds] descriptions = [fld.get("description", "") for fld in flds]
self.widget.show() self.widget.show()
@ -519,14 +522,26 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
text_color = self.mw.pm.profile.get("lastTextColor", "#00f") text_color = self.mw.pm.profile.get("lastTextColor", "#00f")
highlight_color = self.mw.pm.profile.get("lastHighlightColor", "#00f") highlight_color = self.mw.pm.profile.get("lastHighlightColor", "#00f")
js = "setFields({}); setDescriptions({}); setFonts({}); focusField({}); setNoteId({}); setColorButtons({}); setTags({}); ".format( js = """
setFields({});
setPlainTexts({});
setDescriptions({});
setFonts({});
focusField({});
setNoteId({});
setColorButtons({});
setTags({});
setMathjaxEnabled({});
""".format(
json.dumps(data), json.dumps(data),
json.dumps(plain_texts),
json.dumps(descriptions), json.dumps(descriptions),
json.dumps(self.fonts()), json.dumps(self.fonts()),
json.dumps(focusTo), json.dumps(focusTo),
json.dumps(self.note.id), json.dumps(self.note.id),
json.dumps([text_color, highlight_color]), json.dumps([text_color, highlight_color]),
json.dumps(self.note.tags), json.dumps(self.note.tags),
json.dumps(self.mw.col.get_config("renderMathjax", True)),
) )
if self.addMode: if self.addMode:
@ -1130,6 +1145,14 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
def insertMathjaxChemistry(self) -> None: def insertMathjaxChemistry(self) -> None:
self.web.eval("wrap('\\\\(\\\\ce{', '}\\\\)');") self.web.eval("wrap('\\\\(\\\\ce{', '}\\\\)');")
def toggleMathjax(self) -> None:
self.mw.col.set_config(
"renderMathjax", not self.mw.col.get_config("renderMathjax", False)
)
# hackily redraw the page
self.setupWeb()
self.loadNoteKeepingFocus()
# Links from HTML # Links from HTML
###################################################################### ######################################################################
@ -1156,6 +1179,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
mathjaxInline=Editor.insertMathjaxInline, mathjaxInline=Editor.insertMathjaxInline,
mathjaxBlock=Editor.insertMathjaxBlock, mathjaxBlock=Editor.insertMathjaxBlock,
mathjaxChemistry=Editor.insertMathjaxChemistry, mathjaxChemistry=Editor.insertMathjaxChemistry,
toggleMathjax=Editor.toggleMathjax,
) )

View file

@ -243,6 +243,7 @@ class FieldDialog(QDialog):
f.fontSize.setValue(fld["size"]) f.fontSize.setValue(fld["size"])
f.sortField.setChecked(self.model["sortf"] == fld["ord"]) f.sortField.setChecked(self.model["sortf"] == fld["ord"])
f.rtl.setChecked(fld["rtl"]) f.rtl.setChecked(fld["rtl"])
f.plainTextByDefault.setChecked(fld["plainText"])
f.fieldDescription.setText(fld.get("description", "")) f.fieldDescription.setText(fld.get("description", ""))
def saveField(self) -> None: def saveField(self) -> None:
@ -264,6 +265,10 @@ class FieldDialog(QDialog):
if fld["rtl"] != rtl: if fld["rtl"] != rtl:
fld["rtl"] = rtl fld["rtl"] = rtl
self.change_tracker.mark_basic() self.change_tracker.mark_basic()
plain_text = f.plainTextByDefault.isChecked()
if fld["plainText"] != plain_text:
fld["plainText"] = plain_text
self.change_tracker.mark_basic()
desc = f.fieldDescription.text() desc = f.fieldDescription.text()
if fld.get("description", "") != desc: if fld.get("description", "") != desc:
fld["description"] = desc fld["description"] = desc

View file

@ -6,8 +6,8 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>586</width> <width>598</width>
<height>376</height> <height>378</height>
</rect> </rect>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
@ -84,13 +84,6 @@
</item> </item>
<item> <item>
<layout class="QGridLayout" name="_2"> <layout class="QGridLayout" name="_2">
<item row="1" column="0">
<widget class="QLabel" name="label_font">
<property name="text">
<string>fields_editing_font</string>
</property>
</widget>
</item>
<item row="3" column="1"> <item row="3" column="1">
<widget class="QCheckBox" name="rtl"> <widget class="QCheckBox" name="rtl">
<property name="text"> <property name="text">
@ -98,13 +91,27 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="2"> <item row="1" column="1">
<widget class="QSpinBox" name="fontSize"> <widget class="QFontComboBox" name="fontFamily">
<property name="minimum"> <property name="minimumSize">
<number>5</number> <size>
<width>0</width>
<height>25</height>
</size>
</property> </property>
<property name="maximum"> </widget>
<number>300</number> </item>
<item row="1" column="0">
<widget class="QLabel" name="label_font">
<property name="text">
<string>fields_editing_font</string>
</property>
</widget>
</item>
<item row="0" column="1" colspan="2">
<widget class="QLineEdit" name="fieldDescription">
<property name="placeholderText">
<string>fields_description_placeholder</string>
</property> </property>
</widget> </widget>
</item> </item>
@ -115,13 +122,6 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="2" column="1">
<widget class="QRadioButton" name="sortField">
<property name="text">
<string>fields_sort_by_this_field_in_the</string>
</property>
</widget>
</item>
<item row="0" column="0"> <item row="0" column="0">
<widget class="QLabel" name="label_description"> <widget class="QLabel" name="label_description">
<property name="sizePolicy"> <property name="sizePolicy">
@ -135,20 +135,30 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="1"> <item row="1" column="2">
<widget class="QFontComboBox" name="fontFamily"> <widget class="QSpinBox" name="fontSize">
<property name="minimumSize"> <property name="minimum">
<size> <number>5</number>
<width>0</width> </property>
<height>25</height> <property name="maximum">
</size> <number>300</number>
</property> </property>
</widget> </widget>
</item> </item>
<item row="0" column="1" colspan="2"> <item row="2" column="1">
<widget class="QLineEdit" name="fieldDescription"> <widget class="QRadioButton" name="sortField">
<property name="placeholderText"> <property name="text">
<string>fields_description_placeholder</string> <string>fields_sort_by_this_field_in_the</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QCheckBox" name="plainTextByDefault">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>fields_html_by_default</string>
</property> </property>
</widget> </widget>
</item> </item>

View file

@ -42,6 +42,7 @@ impl NoteField {
config: NoteFieldConfig { config: NoteFieldConfig {
sticky: false, sticky: false,
rtl: false, rtl: false,
plain_text: false,
font_name: "Arial".into(), font_name: "Arial".into(),
font_size: 20, font_size: 20,
description: "".into(), description: "".into(),

View file

@ -161,7 +161,7 @@ impl From<Notetype> for NotetypeSchema11 {
/// See [crate::deckconfig::schema11::clear_other_duplicates()]. /// See [crate::deckconfig::schema11::clear_other_duplicates()].
fn clear_other_field_duplicates(other: &mut HashMap<String, Value>) { fn clear_other_field_duplicates(other: &mut HashMap<String, Value>) {
for key in &["description"] { for key in &["description", "plainText"] {
other.remove(*key); other.remove(*key);
} }
} }
@ -195,6 +195,7 @@ impl From<CardRequirement> for CardRequirementSchema11 {
} }
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct NoteFieldSchema11 { pub struct NoteFieldSchema11 {
pub(crate) name: String, pub(crate) name: String,
pub(crate) ord: Option<u16>, pub(crate) ord: Option<u16>,
@ -211,6 +212,9 @@ pub struct NoteFieldSchema11 {
#[serde(default, deserialize_with = "default_on_invalid")] #[serde(default, deserialize_with = "default_on_invalid")]
pub(crate) description: String, pub(crate) description: String,
#[serde(default, deserialize_with = "default_on_invalid")]
pub(crate) plain_text: bool,
#[serde(flatten)] #[serde(flatten)]
pub(crate) other: HashMap<String, Value>, pub(crate) other: HashMap<String, Value>,
} }
@ -222,6 +226,7 @@ impl Default for NoteFieldSchema11 {
ord: None, ord: None,
sticky: false, sticky: false,
rtl: false, rtl: false,
plain_text: false,
font: "Arial".to_string(), font: "Arial".to_string(),
size: 20, size: 20,
description: String::new(), description: String::new(),
@ -238,6 +243,7 @@ impl From<NoteFieldSchema11> for NoteField {
config: NoteFieldConfig { config: NoteFieldConfig {
sticky: f.sticky, sticky: f.sticky,
rtl: f.rtl, rtl: f.rtl,
plain_text: f.plain_text,
font_name: f.font, font_name: f.font,
font_size: f.size as u32, font_size: f.size as u32,
description: f.description, description: f.description,
@ -259,6 +265,7 @@ impl From<NoteField> for NoteFieldSchema11 {
ord: p.ord.map(|o| o as u16), ord: p.ord.map(|o| o as u16),
sticky: conf.sticky, sticky: conf.sticky,
rtl: conf.rtl, rtl: conf.rtl,
plain_text: conf.plain_text,
font: conf.font_name, font: conf.font_name,
size: conf.font_size as u16, size: conf.font_size as u16,
description: conf.description, description: conf.description,

View file

@ -32,7 +32,7 @@ $utilities: (
flex-basis: 75%; flex-basis: 75%;
} }
* { body {
overscroll-behavior: none; overscroll-behavior: none;
} }

View file

@ -38,17 +38,6 @@
} }
onDestroy(() => tooltipObject?.dispose()); onDestroy(() => tooltipObject?.dispose());
// hack to update field description tooltips
let previousTooltip: string = tooltip;
$: if (tooltip !== previousTooltip) {
previousTooltip = tooltip;
if (tooltipObject !== undefined) {
const element: HTMLElement = tooltipObject["_element"];
tooltipObject.dispose();
createTooltip(element);
}
}
</script> </script>
<slot {createTooltip} {tooltipObject} /> <slot {createTooltip} {tooltipObject} />

View file

@ -20,6 +20,10 @@ function trimBreaks(text: string): string {
.replace(/\n*$/, ""); .replace(/\n*$/, "");
} }
export const mathjaxConfig = {
enabled: true,
};
export const Mathjax: DecoratedElementConstructor = class Mathjax export const Mathjax: DecoratedElementConstructor = class Mathjax
extends HTMLElement extends HTMLElement
implements DecoratedElement implements DecoratedElement
@ -41,6 +45,9 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
} }
static toUndecorated(stored: string): string { static toUndecorated(stored: string): string {
if (!mathjaxConfig.enabled) {
return stored;
}
return stored return stored
.replace(mathjaxBlockDelimiterPattern, (_match: string, text: string) => { .replace(mathjaxBlockDelimiterPattern, (_match: string, text: string) => {
const trimmed = trimBreaks(text); const trimmed = trimBreaks(text);

View file

@ -12,6 +12,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
fontFamily: string; fontFamily: string;
fontSize: number; fontSize: number;
direction: "ltr" | "rtl"; direction: "ltr" | "rtl";
plainText: boolean;
description: string; description: string;
} }

View file

@ -32,10 +32,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style> <style>
.field-description { .field-description {
position: absolute; position: absolute;
inset: 0; top: 0;
left: 0;
right: 0;
bottom: 0;
cursor: text;
opacity: 0.4; opacity: 0.4;
pointer-events: none;
/* same as in ContentEditable */ /* same as in ContentEditable */
padding: 6px; padding: 6px;

View file

@ -109,21 +109,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
fieldNames = newFieldNames; fieldNames = newFieldNames;
} }
let plainTexts: boolean[] = [];
let richTextsHidden: boolean[] = [];
let plainTextsHidden: boolean[] = [];
export function setPlainTexts(fs: boolean[]): void {
richTextsHidden = plainTexts = fs;
plainTextsHidden = Array.from(fs, (v) => !v);
}
function setMathjaxEnabled(enabled: boolean): void {
mathjaxConfig.enabled = enabled;
}
let fieldDescriptions: string[] = []; let fieldDescriptions: string[] = [];
export function setDescriptions(fs: string[]): void { export function setDescriptions(fs: string[]): void {
fieldDescriptions = fs; fieldDescriptions = fs;
} }
let fonts: [string, number, boolean][] = []; let fonts: [string, number, boolean][] = [];
let richTextsHidden: boolean[] = [];
let plainTextsHidden: boolean[] = [];
const fields = clearableArray<EditorFieldAPI>(); const fields = clearableArray<EditorFieldAPI>();
export function setFonts(fs: [string, number, boolean][]): void { export function setFonts(fs: [string, number, boolean][]): void {
fonts = fs; fonts = fs;
richTextsHidden = fonts.map((_, index) => richTextsHidden[index] ?? false);
plainTextsHidden = fonts.map((_, index) => plainTextsHidden[index] ?? true);
} }
export function focusField(index: number | null): void { export function focusField(index: number | null): void {
@ -171,6 +179,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: fieldsData = fieldNames.map((name, index) => ({ $: fieldsData = fieldNames.map((name, index) => ({
name, name,
plainText: plainTexts[index],
description: fieldDescriptions[index], description: fieldDescriptions[index],
fontFamily: quoteFontFamily(fonts[index][0]), fontFamily: quoteFontFamily(fonts[index][0]),
fontSize: fonts[index][1], fontSize: fonts[index][1],
@ -223,6 +232,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const toolbar: Partial<EditorToolbarAPI> = {}; const toolbar: Partial<EditorToolbarAPI> = {};
import { mathjaxConfig } from "../editable/mathjax-element";
import { wrapInternal } from "../lib/wrap"; import { wrapInternal } from "../lib/wrap";
import * as oldEditorAdapter from "./old-editor-adapter"; import * as oldEditorAdapter from "./old-editor-adapter";
@ -239,6 +249,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
Object.assign(globalThis, { Object.assign(globalThis, {
setFields, setFields,
setPlainTexts,
setDescriptions, setDescriptions,
setFonts, setFonts,
focusField, focusField,
@ -250,6 +261,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
getNoteId, getNoteId,
setNoteId, setNoteId,
wrap, wrap,
setMathjaxEnabled,
...oldEditorAdapter, ...oldEditorAdapter,
}); });

View file

@ -10,6 +10,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import Popover from "../../components/Popover.svelte"; import Popover from "../../components/Popover.svelte";
import Shortcut from "../../components/Shortcut.svelte"; import Shortcut from "../../components/Shortcut.svelte";
import WithFloating from "../../components/WithFloating.svelte"; import WithFloating from "../../components/WithFloating.svelte";
import { mathjaxConfig } from "../../editable/mathjax-element";
import { bridgeCommand } from "../../lib/bridgecommand";
import * as tr from "../../lib/ftl"; import * as tr from "../../lib/ftl";
import { getPlatformString } from "../../lib/shortcuts"; import { getPlatformString } from "../../lib/shortcuts";
import { wrapInternal } from "../../lib/wrap"; import { wrapInternal } from "../../lib/wrap";
@ -50,6 +52,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
surround("[$$]", "[/$$]"); surround("[$$]", "[/$$]");
} }
function toggleShowMathjax(): void {
mathjaxConfig.enabled = !mathjaxConfig.enabled;
bridgeCommand("toggleMathjax");
}
type LatexItem = [() => void, string, string]; type LatexItem = [() => void, string, string];
const dropdownItems: LatexItem[] = [ const dropdownItems: LatexItem[] = [
@ -93,6 +100,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</DropdownItem> </DropdownItem>
<Shortcut {keyCombination} on:action={callback} /> <Shortcut {keyCombination} on:action={callback} />
{/each} {/each}
<DropdownItem on:click={toggleShowMathjax}>
<span>{tr.editingToggleMathjaxRendering()}</span>
</DropdownItem>
</Popover> </Popover>
</WithFloating> </WithFloating>

View file

@ -5,7 +5,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import Checkbox from "../../components/CheckBox.svelte"; import CheckBox from "../../components/CheckBox.svelte";
import DropdownItem from "../../components/DropdownItem.svelte"; import DropdownItem from "../../components/DropdownItem.svelte";
import DropdownMenu from "../../components/DropdownMenu.svelte"; import DropdownMenu from "../../components/DropdownMenu.svelte";
import { withButton } from "../../components/helpers"; import { withButton } from "../../components/helpers";
@ -14,7 +14,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import WithDropdown from "../../components/WithDropdown.svelte"; import WithDropdown from "../../components/WithDropdown.svelte";
import type { MatchType } from "../../domlib/surround"; import type { MatchType } from "../../domlib/surround";
import * as tr from "../../lib/ftl"; import * as tr from "../../lib/ftl";
import { altPressed } from "../../lib/keys"; import { altPressed, shiftPressed } from "../../lib/keys";
import { getPlatformString } from "../../lib/shortcuts"; import { getPlatformString } from "../../lib/shortcuts";
import { singleCallback } from "../../lib/typing"; import { singleCallback } from "../../lib/typing";
import { surrounder } from "../rich-text-input"; import { surrounder } from "../rich-text-input";
@ -25,45 +25,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const { removeFormats } = editorToolbarContext.get(); const { removeFormats } = editorToolbarContext.get();
const surroundElement = document.createElement("span"); function filterForKeys(formats: RemoveFormat[], value: boolean): string[] {
return formats
function matcher(element: HTMLElement | SVGElement, match: MatchType<never>): void { .filter((format) => format.active === value)
if ( .map((format) => format.key);
element.tagName === "SPAN" &&
element.className.length === 0 &&
element.style.cssText.length === 0
) {
match.remove();
} }
}
const key = "simple spans";
const format = {
matcher,
surroundElement,
};
removeFormats.update((formats) =>
formats.concat({
key,
name: key,
show: false,
active: true,
}),
);
let activeKeys: string[]; let activeKeys: string[];
$: activeKeys = $removeFormats $: activeKeys = filterForKeys($removeFormats, true);
.filter((format) => format.active)
.map((format) => format.key);
let inactiveKeys: string[]; let inactiveKeys: string[];
$: inactiveKeys = $removeFormats $: inactiveKeys = filterForKeys($removeFormats, false);
.filter((format) => !format.active)
.map((format) => format.key);
let showFormats: RemoveFormat[]; let showFormats: RemoveFormat[];
$: showFormats = $removeFormats.filter((format) => format.show); $: showFormats = $removeFormats.filter(
(format: RemoveFormat): boolean => format.show,
);
function remove(): void { function remove(): void {
surrounder.remove(activeKeys, inactiveKeys); surrounder.remove(activeKeys, inactiveKeys);
@ -71,8 +48,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function onItemClick(event: MouseEvent, format: RemoveFormat): void { function onItemClick(event: MouseEvent, format: RemoveFormat): void {
if (altPressed(event)) { if (altPressed(event)) {
const value = shiftPressed(event);
for (const format of showFormats) { for (const format of showFormats) {
format.active = false; format.active = value;
} }
} }
@ -84,12 +63,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let disabled: boolean; let disabled: boolean;
onMount(() => onMount(() => {
singleCallback( const surroundElement = document.createElement("span");
function matcher(
element: HTMLElement | SVGElement,
match: MatchType<never>,
): void {
if (
element.tagName === "SPAN" &&
element.className.length === 0 &&
element.style.cssText.length === 0
) {
match.remove();
}
}
const simpleSpans = {
matcher,
surroundElement,
};
const key = "simple spans";
removeFormats.update((formats: RemoveFormat[]): RemoveFormat[] => [
...formats,
{
key,
name: key,
show: false,
active: true,
},
]);
return singleCallback(
surrounder.active.subscribe((value) => (disabled = !value)), surrounder.active.subscribe((value) => (disabled = !value)),
surrounder.registerFormat(key, format), surrounder.registerFormat(key, simpleSpans),
),
); );
});
</script> </script>
<IconButton <IconButton
@ -117,7 +128,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<DropdownMenu on:mousedown={(event) => event.preventDefault()}> <DropdownMenu on:mousedown={(event) => event.preventDefault()}>
{#each showFormats as format (format.name)} {#each showFormats as format (format.name)}
<DropdownItem on:click={(event) => onItemClick(event, format)}> <DropdownItem on:click={(event) => onItemClick(event, format)}>
<Checkbox bind:value={format.active} /> <CheckBox bind:value={format.active} />
<span class="d-flex-inline ps-3">{format.name}</span> <span class="d-flex-inline ps-3">{format.name}</span>
</DropdownItem> </DropdownItem>
{/each} {/each}

View file

@ -3,6 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script context="module" lang="ts"> <script context="module" lang="ts">
import { writable } from "svelte/store";
import type { ContentEditableAPI } from "../../editable/ContentEditable.svelte"; import type { ContentEditableAPI } from "../../editable/ContentEditable.svelte";
import type { InputHandlerAPI } from "../../sveltelib/input-handler"; import type { InputHandlerAPI } from "../../sveltelib/input-handler";
import type { EditingInputAPI, FocusableInputAPI } from "../EditingArea.svelte"; import type { EditingInputAPI, FocusableInputAPI } from "../EditingArea.svelte";
@ -38,7 +40,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const [globalInputHandler, setupGlobalInputHandler] = useInputHandler(); const [globalInputHandler, setupGlobalInputHandler] = useInputHandler();
const [lifecycle, instances, setupLifecycleHooks] = const [lifecycle, instances, setupLifecycleHooks] =
lifecycleHooks<RichTextInputAPI>(); lifecycleHooks<RichTextInputAPI>();
const surrounder = Surrounder.make(); const apiStore = writable<SurroundedAPI | null>(null);
const surrounder = Surrounder.make(apiStore);
registerPackage("anki/RichTextInput", { registerPackage("anki/RichTextInput", {
context, context,
@ -176,16 +179,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function setFocus(): void { function setFocus(): void {
$focusedInput = api; $focusedInput = api;
surrounder.enable(api); $apiStore = api;
}
function removeFocus(): void {
// We do not unset focusedInput here. // We do not unset focusedInput here.
// If we did, UI components for the input would react the store // If we did, UI components for the input would react the store
// being unset, even though most likely it will be set to some other // being unset, even though most likely it will be set to some other
// field right away. // field right away.
}
function removeFocus(): void { $apiStore = null;
surrounder.disable();
} }
$: pushUpdate(!hidden); $: pushUpdate(!hidden);

View file

@ -1,14 +1,15 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Writable } from "svelte/store"; import type { Readable } from "svelte/store";
import { get, writable } from "svelte/store"; import { derived, get } from "svelte/store";
import type { Matcher } from "../domlib/find-above"; import type { Matcher } from "../domlib/find-above";
import { findClosest } from "../domlib/find-above"; import { findClosest } from "../domlib/find-above";
import type { SurroundFormat } from "../domlib/surround"; import type { SurroundFormat } from "../domlib/surround";
import { boolMatcher, reformat, surround, unsurround } from "../domlib/surround"; import { boolMatcher, reformat, surround, unsurround } from "../domlib/surround";
import { getRange, getSelection } from "../lib/cross-browser"; import { getRange, getSelection } from "../lib/cross-browser";
import { asyncNoop } from "../lib/functional";
import { registerPackage } from "../lib/runtime-require"; import { registerPackage } from "../lib/runtime-require";
import type { TriggerItem } from "../sveltelib/handler-list"; import type { TriggerItem } from "../sveltelib/handler-list";
import type { InputHandlerAPI } from "../sveltelib/input-handler"; import type { InputHandlerAPI } from "../sveltelib/input-handler";
@ -67,52 +68,55 @@ export interface SurroundedAPI {
inputHandler: InputHandlerAPI; inputHandler: InputHandlerAPI;
} }
export class Surrounder<T = unknown> { /**
static make<T>(): Surrounder<T> {
return new Surrounder();
}
private api: SurroundedAPI | null = null;
private triggers: Map<string, TriggerItem<{ event: InputEvent; text: Text }>> =
new Map();
active: Writable<boolean> = writable(false);
enable(api: SurroundedAPI): void {
this.api = api;
this.active.set(true);
for (const key of this.formats.keys()) {
this.triggers.set(
key,
this.api.inputHandler.insertText.trigger({ once: true }),
);
}
}
/**
* After calling disable, using any of the surrounding methods will throw an * After calling disable, using any of the surrounding methods will throw an
* exception. Make sure to set the input before trying to use them again. * exception. Make sure to set the input before trying to use them again.
*/ */
disable(): void { export class Surrounder<T = unknown> {
this.api = null; #api?: SurroundedAPI;
this.active.set(false);
for (const [key, trigger] of this.triggers) { #triggers: Map<string, TriggerItem<{ event: InputEvent; text: Text }>> = new Map();
#formats: Map<string, SurroundFormat<T>> = new Map();
active: Readable<boolean>;
private constructor(apiStore: Readable<SurroundedAPI | null>) {
this.active = derived(apiStore, (api) => Boolean(api));
apiStore.subscribe((api: SurroundedAPI | null): void => {
if (api) {
this.#api = api;
for (const key of this.#formats.keys()) {
this.#triggers.set(
key,
api.inputHandler.insertText.trigger({ once: true }),
);
}
} else {
this.#api = undefined;
for (const [key, trigger] of this.#triggers) {
trigger.off(); trigger.off();
this.triggers.delete(key); this.#triggers.delete(key);
} }
} }
});
}
private async _assert_base(): Promise<HTMLElement> { static make<T>(apiStore: Readable<SurroundedAPI | null>): Surrounder<T> {
if (!this.api) { return new Surrounder(apiStore);
throw new Error("Surrounder: No input set");
} }
return this.api.element; #getBaseElement(): Promise<HTMLElement> {
if (!this.#api) {
throw new Error("Surrounder: No api set");
} }
private _toggleTrigger<T>( return this.#api.element;
}
#toggleTrigger<T>(
base: HTMLElement, base: HTMLElement,
selection: Selection, selection: Selection,
matcher: Matcher, matcher: Matcher,
@ -135,7 +139,7 @@ export class Surrounder<T = unknown> {
} }
} }
private _toggleTriggerOverwrite<T>( #toggleTriggerOverwrite<T>(
base: HTMLElement, base: HTMLElement,
selection: Selection, selection: Selection,
format: SurroundFormat<T>, format: SurroundFormat<T>,
@ -154,51 +158,84 @@ export class Surrounder<T = unknown> {
}); });
} }
private _toggleTriggerRemove<T>( #toggleTriggerRemove<T>(
base: HTMLElement, base: HTMLElement,
selection: Selection, selection: Selection,
remove: SurroundFormat<T>[], formats: {
triggers: TriggerItem<{ event: InputEvent; text: Text }>[], format: SurroundFormat<T>;
trigger: TriggerItem<{ event: InputEvent; text: Text }>;
}[],
reformat: SurroundFormat<T>[] = [], reformat: SurroundFormat<T>[] = [],
): void { ): void {
triggers.map((trigger) => const remainingFormats = formats
trigger.on(async ({ text }) => { .filter(({ trigger }) => {
if (get(trigger.active)) {
// Deactivate active triggers for active formats.
trigger.off();
return false;
}
// Otherwise you are within the format. This is why we activate
// the trigger, so that the active button is set to inactive.
// We still need to remove the format however.
trigger.on(asyncNoop);
return true;
})
.map(({ format }) => format);
// Use an anonymous insertText handler instead of some trigger associated with a name
this.#api!.inputHandler.insertText.on(
async ({ text }) => {
const range = new Range(); const range = new Range();
range.selectNode(text); range.selectNode(text);
const clearedRange = removeFormats(range, base, remove, reformat); const clearedRange = removeFormats(
range,
base,
remainingFormats,
reformat,
);
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(clearedRange); selection.addRange(clearedRange);
selection.collapseToEnd(); selection.collapseToEnd();
}), },
{ once: true },
); );
} }
private formats: Map<string, SurroundFormat<T>> = new Map();
/**
* Register a surround format under a certain name.
* This name is then used with the surround functions to actually apply or
* remove the given format
*/
registerFormat(key: string, format: SurroundFormat<T>): () => void {
this.formats.set(key, format);
if (this.api) {
this.triggers.set(
key,
this.api.inputHandler.insertText.trigger({ once: true }),
);
}
return () => this.formats.delete(key);
}
/** /**
* Check if a surround format under the given key is registered. * Check if a surround format under the given key is registered.
*/ */
hasFormat(key: string): boolean { hasFormat(key: string): boolean {
return this.formats.has(key); return this.#formats.has(key);
}
/**
* Register a surround format under a certain key.
* This name is then used with the surround functions to actually apply or
* remove the given format.
*/
registerFormat(key: string, format: SurroundFormat<T>): () => void {
this.#formats.set(key, format);
if (this.#api) {
this.#triggers.set(
key,
this.#api.inputHandler.insertText.trigger({ once: true }),
);
}
return () => this.#formats.delete(key);
}
/**
* Update a surround format under a specific key.
*/
updateFormat(
key: string,
update: (format: SurroundFormat<T>) => SurroundFormat<T>,
): void {
this.#formats.set(key, update(this.#formats.get(key)!));
} }
/** /**
@ -206,11 +243,11 @@ export class Surrounder<T = unknown> {
* If the range is already surrounded, it will unsurround instead. * If the range is already surrounded, it will unsurround instead.
*/ */
async surround(formatName: string, exclusiveNames: string[] = []): Promise<void> { async surround(formatName: string, exclusiveNames: string[] = []): Promise<void> {
const base = await this._assert_base(); const base = await this.#getBaseElement();
const selection = getSelection(base)!; const selection = getSelection(base)!;
const range = getRange(selection); const range = getRange(selection);
const format = this.formats.get(formatName); const format = this.#formats.get(formatName);
const trigger = this.triggers.get(formatName); const trigger = this.#triggers.get(formatName);
if (!format || !range || !trigger) { if (!format || !range || !trigger) {
return; return;
@ -219,11 +256,11 @@ export class Surrounder<T = unknown> {
const matcher = boolMatcher(format); const matcher = boolMatcher(format);
const exclusives = exclusiveNames const exclusives = exclusiveNames
.map((name) => this.formats.get(name)) .map((name) => this.#formats.get(name))
.filter(isValid); .filter(isValid);
if (range.collapsed) { if (range.collapsed) {
return this._toggleTrigger( return this.#toggleTrigger(
base, base,
selection, selection,
matcher, matcher,
@ -248,22 +285,22 @@ export class Surrounder<T = unknown> {
formatName: string, formatName: string,
exclusiveNames: string[] = [], exclusiveNames: string[] = [],
): Promise<void> { ): Promise<void> {
const base = await this._assert_base(); const base = await this.#getBaseElement();
const selection = getSelection(base)!; const selection = getSelection(base)!;
const range = getRange(selection); const range = getRange(selection);
const format = this.formats.get(formatName); const format = this.#formats.get(formatName);
const trigger = this.triggers.get(formatName); const trigger = this.#triggers.get(formatName);
if (!format || !range || !trigger) { if (!format || !range || !trigger) {
return; return;
} }
const exclusives = exclusiveNames const exclusives = exclusiveNames
.map((name) => this.formats.get(name)) .map((name) => this.#formats.get(name))
.filter(isValid); .filter(isValid);
if (range.collapsed) { if (range.collapsed) {
return this._toggleTriggerOverwrite( return this.#toggleTriggerOverwrite(
base, base,
selection, selection,
format, format,
@ -285,13 +322,13 @@ export class Surrounder<T = unknown> {
* text insert). * text insert).
*/ */
async isSurrounded(formatName: string): Promise<boolean> { async isSurrounded(formatName: string): Promise<boolean> {
const base = await this._assert_base(); const base = await this.#getBaseElement();
const selection = getSelection(base)!; const selection = getSelection(base)!;
const range = getRange(selection); const range = getRange(selection);
const format = this.formats.get(formatName); const format = this.#formats.get(formatName);
const trigger = this.triggers.get(formatName); const trigger = this.#triggers.get(formatName);
if (!format || !range || !trigger) { if (!range || !format || !trigger) {
return false; return false;
} }
@ -303,7 +340,7 @@ export class Surrounder<T = unknown> {
* Clear/Reformat the provided formats in the current range. * Clear/Reformat the provided formats in the current range.
*/ */
async remove(formatNames: string[], reformatNames: string[] = []): Promise<void> { async remove(formatNames: string[], reformatNames: string[] = []): Promise<void> {
const base = await this._assert_base(); const base = await this.#getBaseElement();
const selection = getSelection(base)!; const selection = getSelection(base)!;
const range = getRange(selection); const range = getRange(selection);
@ -311,29 +348,39 @@ export class Surrounder<T = unknown> {
return; return;
} }
const formats = formatNames const activeFormats = formatNames
.map((name) => this.formats.get(name)) .map((name: string) => ({
.filter(isValid); name,
format: this.#formats.get(name)!,
trigger: this.#triggers.get(name)!,
}))
.filter(({ format, trigger }): boolean => {
if (!format || !trigger) {
return false;
}
const triggers = formatNames const isSurrounded = isSurroundedInner(
.map((name) => this.triggers.get(name)) range,
.filter(isValid); base,
boolMatcher(format),
);
return get(trigger.active) ? !isSurrounded : isSurrounded;
});
const reformats = reformatNames const reformats = reformatNames
.map((name) => this.formats.get(name)) .map((name) => this.#formats.get(name))
.filter(isValid); .filter(isValid);
if (range.collapsed) { if (range.collapsed) {
return this._toggleTriggerRemove( return this.#toggleTriggerRemove(base, selection, activeFormats, reformats);
base,
selection,
formats,
triggers,
reformats,
);
} }
const surroundedRange = removeFormats(range, base, formats, reformats); const surroundedRange = removeFormats(
range,
base,
activeFormats.map(({ format }) => format),
reformats,
);
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(surroundedRange); selection.addRange(surroundedRange);
} }

View file

@ -5,6 +5,10 @@ export function noop(): void {
/* noop */ /* noop */
} }
export async function asyncNoop(): Promise<void> {
/* noop */
}
export function id<T>(t: T): T { export function id<T>(t: T): T {
return t; return t;
} }