Move focus into HTML editor when shown (#1861)

* Move focus into input field, when input is shown

* Change trapFocusOut to move focus into available inputs

- This means that e.g. closing the HTML editor with focus in it will
  focus the visual editor in turn

* Prevent Control+A unselecting tag editor when no tags exist
This commit is contained in:
Henrik Giesel 2022-05-13 05:02:03 +02:00 committed by GitHub
parent de2cc20c59
commit 52438fe4c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 120 additions and 25 deletions

View file

@ -7,7 +7,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import contextProperty from "../sveltelib/context-property"; import contextProperty from "../sveltelib/context-property";
export interface EditingInputAPI { export interface FocusableInputAPI {
readonly name: string; readonly name: string;
focusable: boolean; focusable: boolean;
/** /**
@ -22,6 +22,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
refocus(): void; 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 { export interface EditingAreaAPI {
content: Writable<string>; content: Writable<string>;
editingInputs: Writable<EditingInputAPI[]>; editingInputs: Writable<EditingInputAPI[]>;
@ -36,7 +46,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<script lang="ts"> <script lang="ts">
import { setContext as svelteSetContext } from "svelte"; import { setContext as svelteSetContext, tick } from "svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { fontFamilyKey, fontSizeKey } from "../lib/context-keys"; import { fontFamilyKey, fontSizeKey } from "../lib/context-keys";
@ -116,12 +126,39 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
// prevents editor field being entirely deselected when // Prevents editor field being entirely deselected when
// closing active field // closing active field.
function trapFocusOnBlurOut(event: FocusEvent): void { async function trapFocusOnBlurOut(event: FocusEvent): Promise<void> {
if (!event.relatedTarget && editingInputs.every((input) => !input.focusable)) { 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(); focusTrap.focus();
event.preventDefault();
} }
} }

View file

@ -324,8 +324,26 @@ the AddCards dialog) should be implemented in the user of this component.
{#if cols[index] === "dupe"} {#if cols[index] === "dupe"}
<DuplicateLink /> <DuplicateLink />
{/if} {/if}
<RichTextBadge bind:off={richTextsHidden[index]} /> <RichTextBadge
<PlainTextBadge bind:off={plainTextsHidden[index]} /> bind:off={richTextsHidden[index]}
on:toggle={() => {
richTextsHidden[index] = !richTextsHidden[index];
if (!richTextsHidden[index]) {
richTextInputs[index].api.refocus();
}
}}
/>
<PlainTextBadge
bind:off={plainTextsHidden[index]}
on:toggle={() => {
plainTextsHidden[index] = !plainTextsHidden[index];
if (!plainTextsHidden[index]) {
plainTextInputs[index].api.refocus();
}
}}
/>
<slot name="field-state" {field} {index} /> <slot name="field-state" {field} {index} />
</svelte:fragment> </svelte:fragment>

View file

@ -3,7 +3,7 @@ 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 lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { createEventDispatcher, onMount } from "svelte";
import Badge from "../components/Badge.svelte"; import Badge from "../components/Badge.svelte";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
@ -13,13 +13,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const editorField = editorFieldContext.get(); const editorField = editorFieldContext.get();
const keyCombination = "Control+Shift+X"; const keyCombination = "Control+Shift+X";
const dispatch = createEventDispatcher();
export let off = false; export let off = false;
$: icon = off ? htmlOff : htmlOn; $: icon = off ? htmlOff : htmlOn;
function toggle() { function toggle() {
off = !off; dispatch("toggle");
} }
function shortcut(target: HTMLElement): () => void { function shortcut(target: HTMLElement): () => void {

View file

@ -3,14 +3,18 @@ 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 lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte";
import Badge from "../components/Badge.svelte"; import Badge from "../components/Badge.svelte";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import { richTextOff, richTextOn } from "./icons"; import { richTextOff, richTextOn } from "./icons";
export let off: boolean; export let off: boolean;
function toggle(): void { const dispatch = createEventDispatcher();
off = !off;
function toggle() {
dispatch("toggle");
} }
$: icon = off ? richTextOff : richTextOn; $: icon = off ? richTextOff : richTextOn;

View file

@ -6,7 +6,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { registerPackage } from "../../lib/runtime-require"; import { registerPackage } from "../../lib/runtime-require";
import lifecycleHooks from "../../sveltelib/lifecycle-hooks"; import lifecycleHooks from "../../sveltelib/lifecycle-hooks";
import type { CodeMirrorAPI } from "../CodeMirror.svelte"; import type { CodeMirrorAPI } from "../CodeMirror.svelte";
import type { EditingInputAPI } from "../EditingArea.svelte"; import type { EditingInputAPI, FocusableInputAPI } from "../EditingArea.svelte";
export interface PlainTextInputAPI extends EditingInputAPI { export interface PlainTextInputAPI extends EditingInputAPI {
name: "plain-text"; name: "plain-text";
@ -39,6 +39,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import removeProhibitedTags from "./remove-prohibited"; import removeProhibitedTags from "./remove-prohibited";
import { storedToUndecorated, undecoratedToStored } from "./transform"; import { storedToUndecorated, undecoratedToStored } from "./transform";
export let hidden: boolean;
const configuration = { const configuration = {
mode: htmlanki, mode: htmlanki,
...baseOptions, ...baseOptions,
@ -46,7 +48,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}; };
const { focusedInput } = noteEditorContext.get(); const { focusedInput } = noteEditorContext.get();
const { editingInputs, content } = editingAreaContext.get(); const { editingInputs, content } = editingAreaContext.get();
const code = writable($content); const code = writable($content);
@ -70,13 +71,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
moveCaretToEnd(); moveCaretToEnd();
} }
export let hidden = false;
function toggle(): boolean { function toggle(): boolean {
hidden = !hidden; hidden = !hidden;
return hidden; return hidden;
} }
async function getInputAPI(target: EventTarget): Promise<FocusableInputAPI | null> {
const editor = (await codeMirror.editor) as any;
if (target === editor.display.input.textarea) {
return api;
}
return null;
}
export const api: PlainTextInputAPI = { export const api: PlainTextInputAPI = {
name: "plain-text", name: "plain-text",
focus, focus,
@ -84,6 +93,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
moveCaretToEnd, moveCaretToEnd,
refocus, refocus,
toggle, toggle,
getInputAPI,
codeMirror, codeMirror,
}; };

View file

@ -10,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { InputHandlerAPI } from "../../sveltelib/input-handler"; import type { InputHandlerAPI } from "../../sveltelib/input-handler";
import useInputHandler from "../../sveltelib/input-handler"; import useInputHandler from "../../sveltelib/input-handler";
import { pageTheme } from "../../sveltelib/theme"; import { pageTheme } from "../../sveltelib/theme";
import type { EditingInputAPI } from "../EditingArea.svelte"; import type { EditingInputAPI, FocusableInputAPI } from "../EditingArea.svelte";
import type CustomStyles from "./CustomStyles.svelte"; import type CustomStyles from "./CustomStyles.svelte";
export interface RichTextInputAPI extends EditingInputAPI { export interface RichTextInputAPI extends EditingInputAPI {
@ -92,6 +92,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return hidden; return hidden;
} }
const className = "rich-text-editable";
let richTextDiv: HTMLElement;
async function getInputAPI(target: EventTarget): Promise<FocusableInputAPI | null> {
if (target === richTextDiv) {
return api;
}
return null;
}
export const api: RichTextInputAPI = { export const api: RichTextInputAPI = {
name: "rich-text", name: "rich-text",
element: richTextPromise, element: richTextPromise,
@ -99,6 +110,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
refocus, refocus,
focusable: !hidden, focusable: !hidden,
toggle, toggle,
getInputAPI,
moveCaretToEnd, moveCaretToEnd,
preventResubscription, preventResubscription,
inputHandler, inputHandler,
@ -155,7 +167,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let:stylesDidLoad let:stylesDidLoad
> >
<div <div
class="rich-text-editable" bind:this={richTextDiv}
class={className}
class:hidden class:hidden
class:night-mode={$pageTheme.isDark} class:night-mode={$pageTheme.isDark}
use:attachShadow use:attachShadow

View file

@ -346,7 +346,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
function selectAllTags() { function selectAllTags() {
tagTypes.forEach((tag) => (tag.selected = true)); for (const tag of tagTypes) {
tag.selected = true;
}
tagTypes = tagTypes; tagTypes = tagTypes;
} }
@ -451,7 +454,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
splitTag(index, detail.start, detail.end)} splitTag(index, detail.start, detail.end)}
on:tagadd={() => insertTagKeepFocus(index)} on:tagadd={() => insertTagKeepFocus(index)}
on:tagdelete={() => deleteTagAt(index)} on:tagdelete={() => deleteTagAt(index)}
on:tagselectall={selectAllTags} on:tagselectall={async () => {
if (tagTypes.length <= 1) {
// Noop if no other tags exist
return;
}
activeInput.blur();
// Ensure blur events are processed first
await tick();
selectAllTags();
}}
on:tagjoinprevious={() => joinWithPreviousTag(index)} on:tagjoinprevious={() => joinWithPreviousTag(index)}
on:tagjoinnext={() => joinWithNextTag(index)} on:tagjoinnext={() => joinWithNextTag(index)}
on:tagmoveprevious={() => moveToPreviousTag(index)} on:tagmoveprevious={() => moveToPreviousTag(index)}

View file

@ -231,13 +231,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
name = last; name = last;
} }
async function onSelectAll(event: KeyboardEvent) { function onSelectAll(event: KeyboardEvent) {
if (name.length === 0) { if (name.length === 0) {
input.blur();
await tick(); // ensure blur events are processed before tagselectall
dispatch("tagselectall");
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
dispatch("tagselectall");
} }
} }