anki/ts/editor/EditingArea.svelte
Matthias Metelka 5f6ac1a916
Field redesign (#2002)
* Adjust size of legacy buttons

* Revert "Adjust size of legacy buttons"

This reverts commit fb888fe1db9050c34b1a7b0820e6da5ac91ccee6.

* Remove unused function from #1476

* Use outline version for tag icon

* Add chevron icons

* Remove code icons, keep one pin icon version

* Add code-bg color

* Redesign fields

* Remove unused import

* Fix imports

* Move PlainTextBadge between editing inputs

where it belongs :)

* Make whole separator line clickable

* Fix transition

and format

* Don't show toggle when field is collapsed

* Show toggle only on hover

for mobile I'd like to implement a swipe mechanism.

* Use tweened SVG for triangle instead of CSS hack

* Implement more obvious HTML toggle on bottom right

* Reduce field height by a few pixels

* Reduce field height by two pixels

* Show HTML toggle when PlainTextInput is active, regardless of hover/focus

* Remove RichTextBadge.svelte

* Create separate collapsed field state

this means users can collapse fields with the HTML editor open and it will stay open when the field is expanded again.

* Add slide out animation to EditingArea, RichTextInput and PlainTextInput

only for collapsing, because it is choppy on expansion (common issue with Svelte transitions).

* Fix aliasing issue on focused field corners

* Make StickyBadge feel more responsive

* Move StickyBadge closer to field border

* Adjust field gutter/margins

* Make LabelContainer sticky

to make field operations accessible on fields with a lot of content.

* Add back html icons, remove visual editor icons

* Revert "Add code-bg color"

This reverts commit 4200f354193710b3acd9bcf84b67958e200ddcdb.

* Add rich text icon, remove strikethrough code icon

* Revert PlainTextBadge to original position

* Adjust margins in FieldState

* Rename PlainTextBadge to SecondaryInputBadge

in preparation for #1987

* Run eslint and prettier

* Make whole LabelContainer clickable area for collapse/expand

* Revert "Add slide out animation to EditingArea, RichTextInput and PlainTextInput"

This reverts commit 9a2b3410d0ead37ae1da408d68e14507a058a613.

* Fix error on collapse/expansion

this was caused by the {#if} blocks, which resulted in the deletion of original EditingAreas.

* Refocus when toggling chevron and secondary input badge

* Revert "Revert "Add code-bg color""

This reverts commit 1cfd3bda65354ab90c1ab4cbbef47596a1be8754.

* Use single rotating chevron icon and make it RTL-compatible

* Remove redundant CSS transition rule

* Introduce animated Collapsible component and fix refocus on toggle

* Do not try to force repaint, as it is not required

* Remove RTL store from LabelContainer

the direction is already applied globally.

* Collapse secondary input with field

* Add focusedField to NoteEditorAPI

* Replace :global CSS selector with class .visible

thus removing the assumption that the component is used inside an EditorField.

https://github.com/ankitects/anki/pull/2002#discussion_r944876448

* Use named function syntax instead of function expressions

* Add explanation comment

* Remove unnecessary :bind directive

* Create CollapseBadge component

* Move :global selector into .plain-text-input

* Add comment explaining box-shadow pseudo-element

* Move Collapsible from EditingArea, PlainTextInput and RichTextInput into user components

* Rename SecondaryInputBadge to PlainTextBadge and remove generalization logic

I kept the rich text icon inside icons.ts for future use.

* Sort imports

* Fix background-color for duplicates not showing

with yet another pseudo-element :)

The pseudo-element that covers up field borders on scroll caused this issue. Fighting fire with fire here.

* Increase size of plain text toggle to original value again

This makes the clickable area a bit bigger and looks slightly more consistent with StickyBadge.

* Scrap pseudo-element mess in LabelContainer and tackle the actual issue

* Add class .visible to StickyBadge too

This introduces a peculiar bug: The active prop of StickyBadge resets to false when the mouse leaves the field - regardless of the actual back-end value.

* Fix sticky badge resetting on mouseleave/blur

* Apply overflow: hidden only during transition

fixes MathJax handle getting cut off by fields

* Remove unused variable

* Fix visual bug caused by overflow:hidden not applying in time

I tried several asynchronous approaches, but they all caused issues: either they prevented the CSS transition or they made field inputs lose focus.

In the end I resorted to direct, synchronous DOM-manipulation and added an explanatory comment.

* Decrease Collapsible load time by blocking first transition

I noticed the sliding animation has a hefty performance impact when a large number of fields is loaded simultaneously.

Blocking the first transition (which isn't even visible) results in a big boost in load time.

* Replace usages of gap with margins for children

* Revert unnecessary removal of grid-gap definition

* Correct comments about flex-gap property

mistook that for grid-gap.

* Resolve style issues

* Add minimum targets to gap comment

Co-authored-by: Henrik Giesel <hengiesel@gmail.com>
2022-08-19 10:02:28 +10:00

217 lines
6.0 KiB
Svelte

<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script context="module" lang="ts">
import type { Writable } from "svelte/store";
import contextProperty from "../sveltelib/context-property";
export interface FocusableInputAPI {
readonly name: string;
focusable: boolean;
/**
* The reaction to a user-initiated focus, e.g. by clicking on the
* editor label, or pressing Tab.
*/
focus(): void;
/**
* Behaves similar to a refresh, e.g. sync with content, put the caret
* into a neutral position, and/or clear selections.
*/
refocus(): void;
}
export interface EditingInputAPI extends FocusableInputAPI {
/**
* Check whether blurred target belongs to an editing input.
* The editing area can then restore focus to this input.
*
* @returns An editing input api that is associated with the event target.
*/
getInputAPI(target: EventTarget): Promise<FocusableInputAPI | null>;
}
export interface EditingAreaAPI {
content: Writable<string>;
editingInputs: Writable<EditingInputAPI[]>;
focus(): void;
refocus(): void;
}
const key = Symbol("editingArea");
const [context, setContextProperty] = contextProperty<EditingAreaAPI>(key);
export { context };
</script>
<script lang="ts">
import { setContext as svelteSetContext, tick } from "svelte";
import { writable } from "svelte/store";
import { fontFamilyKey, fontSizeKey } from "../lib/context-keys";
import FocusTrap from "./FocusTrap.svelte";
export let fontFamily: string;
const fontFamilyStore = writable(fontFamily);
$: $fontFamilyStore = fontFamily;
svelteSetContext(fontFamilyKey, fontFamilyStore);
export let fontSize: number;
const fontSizeStore = writable(fontSize);
$: $fontSizeStore = fontSize;
svelteSetContext(fontSizeKey, fontSizeStore);
export let content: Writable<string>;
let editingArea: HTMLElement;
let focusTrap: FocusTrap;
const inputsStore = writable<EditingInputAPI[]>([]);
$: editingInputs = $inputsStore;
function getAvailableInput(): EditingInputAPI | undefined {
return editingInputs.find((input) => input.focusable);
}
function focusEditingInputIfAvailable(): boolean {
const availableInput = getAvailableInput();
if (availableInput) {
availableInput.focus();
return true;
}
return false;
}
function focusEditingInputIfFocusTrapFocused(): void {
if (focusTrap && focusTrap.isFocusTrap(document.activeElement!)) {
focusEditingInputIfAvailable();
}
}
$: {
$inputsStore;
/**
* Triggers when all editing inputs are hidden,
* the editor field has focus, and then some
* editing input is shown
*/
focusEditingInputIfFocusTrapFocused();
}
function focus(): void {
if (editingArea.contains(document.activeElement)) {
// do nothing
} else if (!focusEditingInputIfAvailable()) {
focusTrap.focus();
}
}
function refocus(): void {
const availableInput = getAvailableInput();
if (availableInput) {
availableInput.refocus();
} else {
focusTrap.blur();
focusTrap.focus();
}
}
function focusEditingInputInsteadIfAvailable(event: FocusEvent): void {
if (focusEditingInputIfAvailable()) {
event.preventDefault();
}
}
// Prevents editor field being entirely deselected when
// closing active field.
async function trapFocusOnBlurOut(event: FocusEvent): Promise<void> {
if (event.relatedTarget) {
return;
}
event.preventDefault();
const oldInputElement = event.target;
await tick();
let focusableInput: FocusableInputAPI | null = null;
const focusableInputs = editingInputs.filter(
(input: EditingInputAPI): boolean => input.focusable,
);
if (oldInputElement) {
for (const input of focusableInputs) {
focusableInput = await input.getInputAPI(oldInputElement);
if (focusableInput) {
break;
}
}
}
if (focusableInput || (focusableInput = focusableInputs[0])) {
focusableInput.focus();
} else {
focusTrap.focus();
}
}
let apiPartial: Partial<EditingAreaAPI>;
export { apiPartial as api };
const api = Object.assign(apiPartial, {
content,
editingInputs: inputsStore,
focus,
refocus,
});
setContextProperty(api);
</script>
<FocusTrap bind:this={focusTrap} on:focus={focusEditingInputInsteadIfAvailable} />
<div bind:this={editingArea} class="editing-area" on:focusout={trapFocusOnBlurOut}>
<slot />
</div>
<style lang="scss">
.editing-area {
display: grid;
/* TODO allow configuration of grid #1503 */
/* grid-template-columns: repeat(2, 1fr); */
position: relative;
background: var(--frame-bg);
border-radius: 5px;
border: 1px solid var(--border);
box-shadow: 0px 0px 2px 0px var(--border);
transition: box-shadow 80ms cubic-bezier(0.33, 1, 0.68, 1);
&:focus-within {
outline: none;
/* This pseudo-element is required to display
the inset box-shadow above field contents */
&::after {
content: "";
position: absolute;
top: -1px;
right: -1px;
bottom: -1px;
left: -1px;
pointer-events: none;
border-radius: 5px;
box-shadow: inset 0 0 0 2px var(--focus-border);
}
}
}
</style>