Introduce setting to collapse field by default (#1990)

* Introduce setting to collapse field by default

* Fix schema order

* Change wording from adjective to imperative

sounds a bit less clunky

* Update rslib/src/notetype/schema11.rs (dae)

* Keep settings in single column

* Add back Toggle Visual Editor string

* Add RichTextBadge component and show it conditionally

* Reverse input order depending on default setting

* Make PlainTextInput border-radius responsive to toggle states

* Prevent first Collapsible transition differently

* Focus inputs after Collapsible transition

The double tick calls are just a temporary solution until I find the exact moment an input is focusable again.

* Use requestAnimationFrame to await focusable state

Note: Svelte tick doesn't seem to work in this scenario.
This commit is contained in:
Matthias Metelka 2022-08-31 15:34:39 +02:00 committed by GitHub
parent 65601196ee
commit d110c4916c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 248 additions and 83 deletions

View file

@ -53,6 +53,7 @@ editing-text-color = Text color
editing-text-highlight-color = Text highlight color
editing-to-make-a-cloze-deletion-on = To make a cloze deletion on an existing note, you need to change it to a cloze type first, via 'Notes>Change Note Type'
editing-toggle-html-editor = Toggle HTML Editor
editing-toggle-visual-editor = Toggle Visual Editor
editing-toggle-sticky = Toggle sticky
editing-expand-field = Expand field
editing-collapse-field = Collapse field

View file

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

View file

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

View file

@ -501,6 +501,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
]
flds = self.note.note_type()["flds"]
collapsed = [fld["collapsed"] for fld in flds]
plain_texts = [fld.get("plainText", False) for fld in flds]
descriptions = [fld.get("description", "") for fld in flds]
@ -524,6 +525,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
js = """
setFields({});
setCollapsed({});
setPlainTexts({});
setDescriptions({});
setFonts({});
@ -534,6 +536,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
setMathjaxEnabled({});
""".format(
json.dumps(data),
json.dumps(collapsed),
json.dumps(plain_texts),
json.dumps(descriptions),
json.dumps(self.fonts()),

View file

@ -244,6 +244,7 @@ class FieldDialog(QDialog):
f.sortField.setChecked(self.model["sortf"] == fld["ord"])
f.rtl.setChecked(fld["rtl"])
f.plainTextByDefault.setChecked(fld["plainText"])
f.collapseByDefault.setChecked(fld["collapsed"])
f.fieldDescription.setText(fld.get("description", ""))
def saveField(self) -> None:
@ -269,6 +270,9 @@ class FieldDialog(QDialog):
if fld["plainText"] != plain_text:
fld["plainText"] = plain_text
self.change_tracker.mark_basic()
collapsed = f.collapseByDefault.isChecked()
if fld["collapsed"] != collapsed:
fld["collapsed"] = collapsed
desc = f.fieldDescription.text()
if fld.get("description", "") != desc:
fld["description"] = desc

View file

@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>598</width>
<height>378</height>
<width>567</width>
<height>438</height>
</rect>
</property>
<property name="windowTitle">
@ -84,44 +84,6 @@
</item>
<item>
<layout class="QGridLayout" name="_2">
<item row="3" column="1">
<widget class="QCheckBox" name="rtl">
<property name="text">
<string>fields_reverse_text_direction_rtl</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QFontComboBox" name="fontFamily">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
</widget>
</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>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_sort">
<property name="text">
<string>actions_options</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_description">
<property name="sizePolicy">
@ -135,6 +97,13 @@
</property>
</widget>
</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="1" column="2">
<widget class="QSpinBox" name="fontSize">
<property name="minimum">
@ -145,10 +114,27 @@
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QRadioButton" name="sortField">
<item row="2" column="0">
<widget class="QLabel" name="label_sort">
<property name="text">
<string>fields_sort_by_this_field_in_the</string>
<string>actions_options</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QFontComboBox" name="fontFamily">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QCheckBox" name="rtl">
<property name="text">
<string>fields_reverse_text_direction_rtl</string>
</property>
</widget>
</item>
@ -162,6 +148,30 @@
</property>
</widget>
</item>
<item row="0" column="1" colspan="2">
<widget class="QLineEdit" name="fieldDescription">
<property name="placeholderText">
<string>fields_description_placeholder</string>
</property>
</widget>
</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="5" column="1">
<widget class="QCheckBox" name="collapseByDefault">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>fields_collapse_by_default</string>
</property>
</widget>
</item>
</layout>
</item>
<item>

View file

@ -46,6 +46,7 @@ impl NoteField {
font_name: "Arial".into(),
font_size: 20,
description: "".into(),
collapsed: false,
other: vec![],
},
}

View file

@ -161,7 +161,7 @@ impl From<Notetype> for NotetypeSchema11 {
/// See [crate::deckconfig::schema11::clear_other_duplicates()].
fn clear_other_field_duplicates(other: &mut HashMap<String, Value>) {
for key in &["description", "plainText"] {
for key in &["description", "plainText", "collapsed"] {
other.remove(*key);
}
}
@ -215,6 +215,9 @@ pub struct NoteFieldSchema11 {
#[serde(default, deserialize_with = "default_on_invalid")]
pub(crate) plain_text: bool,
#[serde(default, deserialize_with = "default_on_invalid")]
pub(crate) collapsed: bool,
#[serde(flatten)]
pub(crate) other: HashMap<String, Value>,
}
@ -230,6 +233,7 @@ impl Default for NoteFieldSchema11 {
font: "Arial".to_string(),
size: 20,
description: String::new(),
collapsed: false,
other: Default::default(),
}
}
@ -247,6 +251,7 @@ impl From<NoteFieldSchema11> for NoteField {
font_name: f.font,
font_size: f.size as u32,
description: f.description,
collapsed: f.collapsed,
other: other_to_bytes(&f.other),
},
}
@ -269,6 +274,7 @@ impl From<NoteField> for NoteFieldSchema11 {
font: conf.font_name,
size: conf.font_size as u16,
description: conf.description,
collapsed: conf.collapsed,
other,
}
}

View file

@ -10,12 +10,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export { className as class };
export let collapsed = false;
let isCollapsed = false;
let hidden = collapsed;
const [outerPromise, outerResolve] = promiseWithResolver<HTMLElement>();
const [innerPromise, innerResolve] = promiseWithResolver<HTMLElement>();
let isCollapsed = false;
let style: string;
function setStyle(height: number, duration: number) {
style = `--collapse-height: -${height}px; --duration: ${duration}ms`;
@ -60,18 +60,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
() => {
inner.toggleAttribute("hidden", collapse);
outer.style.removeProperty("overflow");
hidden = collapse;
},
{ once: true },
);
}
/* prevent transition on mount for performance reasons */
let blockTransition = true;
let firstTransition = true;
$: if (blockTransition) {
blockTransition = false;
} else {
$: {
transition(collapsed);
firstTransition = false;
}
</script>
@ -79,10 +79,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div
class="collapsible-inner"
class:collapsed={isCollapsed}
class:no-transition={firstTransition}
use:innerResolve
{style}
>
<slot />
<slot {hidden} />
</div>
</div>
@ -96,5 +97,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
&.collapsed {
margin-top: var(--collapse-height);
}
&.no-transition {
transition: none;
}
}
</style>

View file

@ -14,6 +14,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
direction: "ltr" | "rtl";
plainText: boolean;
description: string;
collapsed: boolean;
}
export interface EditorFieldAPI {
@ -54,6 +55,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let content: Writable<string>;
export let field: FieldData;
export let collapsed = false;
export let flipInputs = false;
const directionStore = writable<"ltr" | "rtl">();
setContext(directionKey, directionStore);
@ -101,7 +103,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
fontSize={field.fontSize}
api={editingArea}
>
<slot name="editing-inputs" />
{#if flipInputs}
<slot name="plain-text-input" />
<slot name="rich-text-input" />
{:else}
<slot name="rich-text-input" />
<slot name="plain-text-input" />
{/if}
</EditingArea>
</Collapsible>
</div>

View file

@ -66,6 +66,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import PlainTextInput from "./plain-text-input";
import PlainTextBadge from "./PlainTextBadge.svelte";
import RichTextInput, { editingInputIsRichText } from "./rich-text-input";
import RichTextBadge from "./RichTextBadge.svelte";
function quoteFontFamily(fontFamily: string): string {
// generic families (e.g. sans-serif) must not be quoted
@ -113,13 +114,19 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
fieldNames = newFieldNames;
}
let plainTexts: boolean[] = [];
let fieldsCollapsed: boolean[] = [];
export function setCollapsed(fs: boolean[]): void {
fieldsCollapsed = fs;
}
let richTextsHidden: boolean[] = [];
let plainTextsHidden: boolean[] = [];
let plainTextDefaults: boolean[] = [];
export function setPlainTexts(fs: boolean[]): void {
richTextsHidden = plainTexts = fs;
richTextsHidden = fs;
plainTextsHidden = Array.from(fs, (v) => !v);
plainTextDefaults = [...richTextsHidden];
}
function setMathjaxEnabled(enabled: boolean): void {
@ -132,13 +139,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
let fonts: [string, number, boolean][] = [];
let fieldsCollapsed: boolean[] = [];
const fields = clearableArray<EditorFieldAPI>();
export function setFonts(fs: [string, number, boolean][]): void {
fonts = fs;
fieldsCollapsed = fonts.map((_, index) => fieldsCollapsed[index] ?? false);
}
export function focusField(index: number | null): void {
@ -186,11 +191,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: fieldsData = fieldNames.map((name, index) => ({
name,
plainText: plainTexts[index],
plainText: plainTextDefaults[index],
description: fieldDescriptions[index],
fontFamily: quoteFontFamily(fonts[index][0]),
fontSize: fonts[index][1],
direction: fonts[index][2] ? "rtl" : "ltr",
collapsed: fieldsCollapsed[index],
})) as FieldData[];
function saveTags({ detail }: CustomEvent): void {
@ -241,6 +247,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { mathjaxConfig } from "../editable/mathjax-element";
import { wrapInternal } from "../lib/wrap";
import { refocusInput } from "./helpers";
import * as oldEditorAdapter from "./old-editor-adapter";
onMount(() => {
@ -256,6 +263,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
Object.assign(globalThis, {
setFields,
setCollapsed,
setPlainTexts,
setDescriptions,
setFonts,
@ -329,6 +337,7 @@ the AddCards dialog) should be implemented in the user of this component.
<EditorField
{field}
{content}
flipInputs={plainTextDefaults[index]}
api={fields[index]}
on:focusin={() => {
$focusedField = fields[index];
@ -357,11 +366,16 @@ the AddCards dialog) should be implemented in the user of this component.
on:toggle={async () => {
fieldsCollapsed[index] = !fieldsCollapsed[index];
const defaultInput = !plainTextDefaults[index]
? richTextInputs[index]
: plainTextInputs[index];
if (!fieldsCollapsed[index]) {
await tick();
richTextInputs[index].api.refocus();
} else {
refocusInput(defaultInput.api);
} else if (!plainTextDefaults[index]) {
plainTextsHidden[index] = true;
} else {
richTextsHidden[index] = true;
}
}}
>
@ -374,21 +388,41 @@ the AddCards dialog) should be implemented in the user of this component.
{#if cols[index] === "dupe"}
<DuplicateLink />
{/if}
<PlainTextBadge
visible={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField ||
fields[index] === $focusedField)}
bind:off={plainTextsHidden[index]}
on:toggle={async () => {
plainTextsHidden[index] =
!plainTextsHidden[index];
{#if plainTextDefaults[index]}
<RichTextBadge
visible={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField ||
fields[index] === $focusedField)}
bind:off={richTextsHidden[index]}
on:toggle={async () => {
richTextsHidden[index] =
!richTextsHidden[index];
if (!plainTextsHidden[index]) {
await tick();
plainTextInputs[index].api.refocus();
}
}}
/>
if (!richTextsHidden[index]) {
refocusInput(
richTextInputs[index].api,
);
}
}}
/>
{:else}
<PlainTextBadge
visible={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField ||
fields[index] === $focusedField)}
bind:off={plainTextsHidden[index]}
on:toggle={async () => {
plainTextsHidden[index] =
!plainTextsHidden[index];
if (!plainTextsHidden[index]) {
refocusInput(
plainTextInputs[index].api,
);
}
}}
/>
{/if}
<slot
name="field-state"
{field}
@ -399,10 +433,10 @@ the AddCards dialog) should be implemented in the user of this component.
</FieldState>
</LabelContainer>
</svelte:fragment>
<svelte:fragment slot="editing-inputs">
<Collapsible collapsed={richTextsHidden[index]}>
<svelte:fragment slot="rich-text-input">
<Collapsible collapsed={richTextsHidden[index]} let:hidden>
<RichTextInput
bind:hidden={richTextsHidden[index]}
{hidden}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;
@ -416,10 +450,13 @@ the AddCards dialog) should be implemented in the user of this component.
</FieldDescription>
</RichTextInput>
</Collapsible>
<Collapsible collapsed={plainTextsHidden[index]}>
</svelte:fragment>
<svelte:fragment slot="plain-text-input">
<Collapsible collapsed={plainTextsHidden[index]} let:hidden>
<PlainTextInput
bind:hidden={plainTextsHidden[index]}
{hidden}
isDefault={plainTextDefaults[index]}
richTextHidden={richTextsHidden[index]}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;

View file

@ -0,0 +1,59 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
import Badge from "../components/Badge.svelte";
import * as tr from "../lib/ftl";
import { getPlatformString, registerShortcut } from "../lib/shortcuts";
import { context as editorFieldContext } from "./EditorField.svelte";
import { richTextIcon } from "./icons";
const editorField = editorFieldContext.get();
const keyCombination = "Control+Shift+X";
const dispatch = createEventDispatcher();
export let visible = false;
export let off = false;
function toggle() {
dispatch("toggle");
}
function shortcut(target: HTMLElement): () => void {
return registerShortcut(toggle, keyCombination, { target });
}
onMount(() => editorField.element.then(shortcut));
</script>
<span
class="plain-text-badge"
class:visible
class:highlighted={!off}
on:click|stopPropagation={toggle}
>
<Badge
tooltip="{tr.editingToggleVisualEditor()} ({getPlatformString(keyCombination)})"
iconSize={80}>{@html richTextIcon}</Badge
>
</span>
<style lang="scss">
span {
cursor: pointer;
opacity: 0;
&.visible {
opacity: 0.4;
&:hover {
opacity: 0.8;
}
}
&.highlighted {
opacity: 1;
}
}
</style>

View file

@ -1,6 +1,9 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { PlainTextInputAPI } from "./plain-text-input";
import type { RichTextInputAPI } from "./rich-text-input";
function isFontElement(element: Element): element is HTMLFontElement {
return element.tagName === "FONT";
}
@ -19,3 +22,15 @@ export function withFontColor(
return false;
}
/***
* Required for field inputs wrapped in Collapsible
*/
export async function refocusInput(
api: RichTextInputAPI | PlainTextInputAPI,
): Promise<void> {
do {
await new Promise(window.requestAnimationFrame);
} while (!api.focusable);
api.refocus();
}

View file

@ -39,7 +39,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import removeProhibitedTags from "./remove-prohibited";
import { storedToUndecorated, undecoratedToStored } from "./transform";
export let isDefault: boolean;
export let hidden: boolean;
export let richTextHidden: boolean;
const configuration = {
mode: htmlanki,
@ -143,6 +145,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div
class="plain-text-input"
class:light-theme={!$pageTheme.isDark}
class:is-default={isDefault}
class:alone={richTextHidden}
on:focusin={() => ($focusedInput = api)}
>
<CodeMirror
@ -156,11 +160,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss">
.plain-text-input {
overflow-x: hidden;
overflow: hidden;
border-top: 1px solid var(--border);
border-radius: 0 0 5px 5px;
&.is-default {
border-top: none;
border-bottom: 1px solid var(--border);
border-radius: 5px 5px 0 0;
}
&.alone {
border: none;
border-radius: 5px;
}
:global(.CodeMirror) {
border-radius: 0 0 5px 5px;
border-top: 1px solid var(--border);
background: var(--code-bg);
}
:global(.CodeMirror-lines) {