Refactor how toolbar buttons get to surround within editor fields (#1931)

* Export surrounder directly from RichTextInput

* Change wording in editor/surround

* Remove empty line

* Change wording

* Fix interfaces

* Add field description directly in NoteEditor

* Strip description logic from ContentEditable

* Make RichTextInput position: relative

* Make attachToShadow an async function

* Apply field styling to field description

* Show FieldDescription only if content empty

* Remove descriptionStore and descriptionKey

* Revert "Make attachToShadow an async function"

This reverts commit b62705eadf7335c7ee0c6c8797047e1f1ccdbf44.

SvelteActionReturnType does not accept Promise<void>

* Fix mess after merge commit

* Require registering surround formats
This commit is contained in:
Henrik Giesel 2022-08-15 05:34:16 +02:00 committed by GitHub
parent d5945a213a
commit 9ca13ca3bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 460 additions and 364 deletions

View File

@ -169,14 +169,15 @@ export class FormattingNode<T = never> extends TreeNode {
* When surrounding "inside" with a bold format in the following case:
* `<span class="myclass"><em>inside</em></span>`
* The formatting node would sit above the span (it ascends above both
* the span and the em tag), and both tags are extensions to this node.
* the em and the span tag), and its extensions are the span tag and the
* em tag (in this order).
*
* @example
* When a format only wants to add a class, it would typically look for an
* extension first. When applying class="myclass" to "inside" in the
* following case:
* `<em><span style="color: rgb(255, 0, 0)"><b>inside</b></span></em>`
* It would typically become:
* It should typically become:
* `<em><span class="myclass" style="color: rgb(255, 0, 0)"><b>inside</b></span></em>`
*/
extensions: (HTMLElement | SVGElement)[] = [];

View File

@ -44,7 +44,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Writable } from "svelte/store";
import { writable } from "svelte/store";
import { descriptionKey, directionKey } from "../lib/context-keys";
import { directionKey } from "../lib/context-keys";
import { promiseWithResolver } from "../lib/promise";
import type { Destroyable } from "./destroyable";
import EditingArea from "./EditingArea.svelte";
@ -60,11 +60,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: $directionStore = field.direction;
const descriptionStore = writable<string>();
setContext(descriptionKey, descriptionStore);
$: $descriptionStore = field.description;
const editingArea: Partial<EditingAreaAPI> = {};
const [element, elementResolve] = promiseWithResolver<HTMLElement>();

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 { getContext } from "svelte";
import type { Readable } from "svelte/store";
import { directionKey, fontFamilyKey, fontSizeKey } from "../lib/context-keys";
import { context } from "./EditingArea.svelte";
const { content } = context.get();
const fontFamily = getContext<Readable<string>>(fontFamilyKey);
const fontSize = getContext<Readable<number>>(fontSizeKey);
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey);
$: empty = $content.length === 0;
</script>
{#if empty}
<div
class="field-description"
style:font-family={$fontFamily}
style:font-size="{$fontSize}px"
style:direction={$direction}
>
<slot />
</div>
{/if}
<style>
.field-description {
position: absolute;
inset: 0;
cursor: text;
opacity: 0.4;
/* same as in ContentEditable */
padding: 6px;
/* stay a on single line */
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

View File

@ -46,20 +46,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import DecoratedElements from "./DecoratedElements.svelte";
import { clearableArray } from "./destroyable";
import DuplicateLink from "./DuplicateLink.svelte";
import { EditorToolbar } from "./editor-toolbar";
import EditorToolbar from "./editor-toolbar";
import type { FieldData } from "./EditorField.svelte";
import EditorField from "./EditorField.svelte";
import FieldDescription from "./FieldDescription.svelte";
import Fields from "./Fields.svelte";
import FieldsEditor from "./FieldsEditor.svelte";
import FrameElement from "./FrameElement.svelte";
import { alertIcon } from "./icons";
import { ImageHandle } from "./image-overlay";
import { MathjaxHandle } from "./mathjax-overlay";
import ImageHandle from "./image-overlay";
import MathjaxHandle from "./mathjax-overlay";
import MathjaxElement from "./MathjaxElement.svelte";
import Notification from "./Notification.svelte";
import { PlainTextInput } from "./plain-text-input";
import PlainTextInput from "./plain-text-input";
import PlainTextBadge from "./PlainTextBadge.svelte";
import { editingInputIsRichText, RichTextInput } from "./rich-text-input";
import RichTextInput, { editingInputIsRichText } from "./rich-text-input";
import RichTextBadge from "./RichTextBadge.svelte";
function quoteFontFamily(fontFamily: string): string {
@ -302,9 +303,11 @@ the AddCards dialog) should be implemented in the user of this component.
<Fields>
<DecoratedElements>
{#each fieldsData as field, index}
{@const content = fieldStores[index]}
<EditorField
{field}
content={fieldStores[index]}
{content}
api={fields[index]}
on:focusin={() => {
$focusedField = fields[index];
@ -313,9 +316,7 @@ the AddCards dialog) should be implemented in the user of this component.
on:focusout={() => {
$focusedField = null;
bridgeCommand(
`blur:${index}:${getNoteId()}:${get(
fieldStores[index],
)}`,
`blur:${index}:${getNoteId()}:${get(content)}`,
);
}}
--label-color={cols[index] === "dupe"
@ -361,6 +362,9 @@ the AddCards dialog) should be implemented in the user of this component.
>
<ImageHandle maxWidth={250} maxHeight={125} />
<MathjaxHandle />
<FieldDescription>
{field.description}
</FieldDescription>
</RichTextInput>
<PlainTextInput

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
-->
<script lang="ts">
import { onMount } from "svelte";
import IconButton from "../../components/IconButton.svelte";
import Shortcut from "../../components/Shortcut.svelte";
import WithState from "../../components/WithState.svelte";
@ -10,9 +12,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "../../lib/ftl";
import { getPlatformString } from "../../lib/shortcuts";
import { removeStyleProperties } from "../../lib/styling";
import { context as noteEditorContext } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import { Surrounder } from "../surround";
import { singleCallback } from "../../lib/typing";
import { surrounder } from "../rich-text-input";
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import { boldIcon } from "./icons";
@ -36,50 +37,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
}
const key = "bold";
const format = {
surroundElement,
matcher,
};
const namedFormat = {
key,
name: tr.editingBoldText(),
show: true,
active: true,
format,
};
const { removeFormats } = editorToolbarContext.get();
removeFormats.update((formats) => [...formats, namedFormat]);
const { focusedInput } = noteEditorContext.get();
const surrounder = Surrounder.make();
let disabled: boolean;
$: if (editingInputIsRichText($focusedInput)) {
surrounder.richText = $focusedInput;
disabled = false;
} else {
surrounder.disable();
disabled = true;
}
function updateStateFromActiveInput(): Promise<boolean> {
return disabled ? Promise.resolve(false) : surrounder.isSurrounded(format);
async function updateStateFromActiveInput(): Promise<boolean> {
return disabled ? false : surrounder.isSurrounded(key);
}
function makeBold(): void {
surrounder.surround(format);
surrounder.surround(key);
}
const keyCombination = "Control+B";
let disabled: boolean;
onMount(() =>
singleCallback(
surrounder.active.subscribe((value) => (disabled = !value)),
surrounder.registerFormat(key, format),
),
);
</script>
<WithState
key="bold"
update={updateStateFromActiveInput}
let:state={active}
let:updateState
>
<WithState {key} update={updateStateFromActiveInput} let:state={active} let:updateState>
<IconButton
tooltip="{tr.editingBoldText()} ({getPlatformString(keyCombination)})"
{active}

View File

@ -6,7 +6,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Writable } from "svelte/store";
import { resetAllState, updateAllState } from "../../components/WithState.svelte";
import type { SurroundFormat } from "../../domlib/surround";
import type { DefaultSlotInterface } from "../../sveltelib/dynamic-slotting";
export function updateActiveButtons(event: Event) {
@ -17,11 +16,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
resetAllState(false);
}
export interface RemoveFormat<T> {
export interface RemoveFormat {
name: string;
key: string;
show: boolean;
active: boolean;
format: SurroundFormat<T>;
}
export interface EditorToolbarAPI {
@ -30,7 +29,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
inlineButtons: DefaultSlotInterface;
blockButtons: DefaultSlotInterface;
templateButtons: DefaultSlotInterface;
removeFormats: Writable<RemoveFormat<any>[]>;
removeFormats: Writable<RemoveFormat[]>;
}
/* Our dynamic components */
@ -69,7 +68,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const inlineButtons = {} as DefaultSlotInterface;
const blockButtons = {} as DefaultSlotInterface;
const templateButtons = {} as DefaultSlotInterface;
const removeFormats = writable<RemoveFormat<any>[]>([]);
const removeFormats = writable<RemoveFormat[]>([]);
let apiPartial: Partial<EditorToolbarAPI> = {};
export { apiPartial as api };

View File

@ -3,20 +3,16 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { onMount } from "svelte";
import IconButton from "../../components/IconButton.svelte";
import type {
FormattingNode,
MatchType,
SurroundFormat,
} from "../../domlib/surround";
import type { FormattingNode, MatchType } from "../../domlib/surround";
import { bridgeCommand } from "../../lib/bridgecommand";
import * as tr from "../../lib/ftl";
import { removeStyleProperties } from "../../lib/styling";
import { context as noteEditorContext } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import { Surrounder } from "../surround";
import { singleCallback } from "../../lib/typing";
import { surrounder } from "../rich-text-input";
import ColorPicker from "./ColorPicker.svelte";
import type { RemoveFormat } from "./EditorToolbar.svelte";
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import { arrowIcon, highlightColorIcon } from "./icons";
import WithColorHelper from "./WithColorHelper.svelte";
@ -79,37 +75,36 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return true;
}
const format: SurroundFormat<string> = {
const key = "highlightColor";
const format = {
matcher,
merger,
formatter,
};
const namedFormat: RemoveFormat<string> = {
const namedFormat = {
key,
name: tr.editingTextHighlightColor(),
show: true,
active: true,
format,
};
const { removeFormats } = editorToolbarContext.get();
removeFormats.update((formats) => [...formats, namedFormat]);
const { focusedInput } = noteEditorContext.get();
const surrounder = Surrounder.make();
function setTextColor(): void {
surrounder.overwriteSurround(key);
}
let disabled: boolean;
$: if (editingInputIsRichText($focusedInput)) {
disabled = false;
surrounder.richText = $focusedInput;
} else {
disabled = true;
surrounder.disable();
}
function setTextColor(): void {
surrounder.overwriteSurround(format);
}
onMount(() =>
singleCallback(
surrounder.active.subscribe((value) => (disabled = !value)),
surrounder.registerFormat(key, format),
),
);
</script>
<WithColorHelper {color} let:colorHelperIcon let:setColor>

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
-->
<script lang="ts">
import { onMount } from "svelte";
import IconButton from "../../components/IconButton.svelte";
import Shortcut from "../../components/Shortcut.svelte";
import WithState from "../../components/WithState.svelte";
@ -10,9 +12,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "../../lib/ftl";
import { getPlatformString } from "../../lib/shortcuts";
import { removeStyleProperties } from "../../lib/styling";
import { context as noteEditorContext } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import { Surrounder } from "../surround";
import { singleCallback } from "../../lib/typing";
import { surrounder } from "../rich-text-input";
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import { italicIcon } from "./icons";
@ -35,50 +36,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
}
const key = "italic";
const format = {
surroundElement,
matcher,
};
const namedFormat = {
key,
name: tr.editingItalicText(),
show: true,
active: true,
format,
};
const { removeFormats } = editorToolbarContext.get();
removeFormats.update((formats) => [...formats, namedFormat]);
const { focusedInput } = noteEditorContext.get();
const surrounder = Surrounder.make();
let disabled: boolean;
$: if (editingInputIsRichText($focusedInput)) {
surrounder.richText = $focusedInput;
disabled = false;
} else {
surrounder.disable();
disabled = true;
}
function updateStateFromActiveInput(): Promise<boolean> {
return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(format);
async function updateStateFromActiveInput(): Promise<boolean> {
return disabled ? false : surrounder.isSurrounded(key);
}
function makeItalic(): void {
surrounder.surround(format);
surrounder.surround(key);
}
const keyCombination = "Control+I";
let disabled: boolean;
onMount(() =>
singleCallback(
surrounder.active.subscribe((value) => (disabled = !value)),
surrounder.registerFormat(key, format),
),
);
</script>
<WithState
key="italic"
update={updateStateFromActiveInput}
let:state={active}
let:updateState
>
<WithState {key} update={updateStateFromActiveInput} let:state={active} let:updateState>
<IconButton
tooltip="{tr.editingItalicText()} ({getPlatformString(keyCombination)})"
{active}

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
-->
<script lang="ts">
import { onMount } from "svelte";
import Checkbox from "../../components/CheckBox.svelte";
import DropdownItem from "../../components/DropdownItem.svelte";
import DropdownMenu from "../../components/DropdownMenu.svelte";
@ -10,74 +12,64 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import IconButton from "../../components/IconButton.svelte";
import Shortcut from "../../components/Shortcut.svelte";
import WithDropdown from "../../components/WithDropdown.svelte";
import type { SurroundFormat } from "../../domlib/surround";
import type { MatchType } from "../../domlib/surround";
import * as tr from "../../lib/ftl";
import { altPressed } from "../../lib/keys";
import { getPlatformString } from "../../lib/shortcuts";
import { context as noteEditorContext } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import { Surrounder } from "../surround";
import { singleCallback } from "../../lib/typing";
import { surrounder } from "../rich-text-input";
import type { RemoveFormat } from "./EditorToolbar.svelte";
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import { eraserIcon } from "./icons";
import { arrowIcon } from "./icons";
const { focusedInput } = noteEditorContext.get();
const surrounder = Surrounder.make();
let disabled: boolean;
const { removeFormats } = editorToolbarContext.get();
$: if (editingInputIsRichText($focusedInput)) {
surrounder.richText = $focusedInput;
disabled = false;
} else {
surrounder.disable();
disabled = true;
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 { removeFormats } = editorToolbarContext.get();
const key = "simple spans";
const format = {
matcher,
surroundElement,
};
removeFormats.update((formats) =>
formats.concat({
name: "simple spans",
key,
name: key,
show: false,
active: true,
format: {
matcher: (
element: HTMLElement | SVGElement,
match: MatchType<never>,
): void => {
if (
element.tagName === "SPAN" &&
element.className.length === 0 &&
element.style.cssText.length === 0
) {
match.remove();
}
},
surroundElement: document.createElement("span"),
},
}),
);
let activeFormats: SurroundFormat<any>[];
$: activeFormats = $removeFormats
let activeKeys: string[];
$: activeKeys = $removeFormats
.filter((format) => format.active)
.map((format) => format.format);
.map((format) => format.key);
let inactiveFormats: SurroundFormat<any>[];
$: inactiveFormats = $removeFormats
let inactiveKeys: string[];
$: inactiveKeys = $removeFormats
.filter((format) => !format.active)
.map((format) => format.format);
.map((format) => format.key);
let showFormats: RemoveFormat<any>[];
let showFormats: RemoveFormat[];
$: showFormats = $removeFormats.filter((format) => format.show);
function remove(): void {
surrounder.remove(activeFormats, inactiveFormats);
surrounder.remove(activeKeys, inactiveKeys);
}
function onItemClick<T>(event: MouseEvent, format: RemoveFormat<T>): void {
function onItemClick(event: MouseEvent, format: RemoveFormat): void {
if (altPressed(event)) {
for (const format of showFormats) {
format.active = false;
@ -89,6 +81,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
const keyCombination = "Control+R";
let disabled: boolean;
onMount(() =>
singleCallback(
surrounder.active.subscribe((value) => (disabled = !value)),
surrounder.registerFormat(key, format),
),
);
</script>
<IconButton

View File

@ -2,9 +2,21 @@
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">
<script lang="ts">
import { onMount } from "svelte";
import IconButton from "../../components/IconButton.svelte";
import Shortcut from "../../components/Shortcut.svelte";
import WithState from "../../components/WithState.svelte";
import { updateStateByKey } from "../../components/WithState.svelte";
import type { MatchType } from "../../domlib/surround";
import * as tr from "../../lib/ftl";
import { getPlatformString } from "../../lib/shortcuts";
import { removeStyleProperties } from "../../lib/styling";
import { singleCallback } from "../../lib/typing";
import { surrounder } from "../rich-text-input";
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import { subscriptIcon } from "./icons";
const surroundElement = document.createElement("sub");
@ -25,65 +37,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
}
export const format = {
const key = "subscript";
const format = {
surroundElement,
matcher,
};
</script>
<script lang="ts">
import IconButton from "../../components/IconButton.svelte";
import Shortcut from "../../components/Shortcut.svelte";
import WithState from "../../components/WithState.svelte";
import { updateStateByKey } from "../../components/WithState.svelte";
import * as tr from "../../lib/ftl";
import { getPlatformString } from "../../lib/shortcuts";
import { context as noteEditorContext } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import { Surrounder } from "../surround";
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import { subscriptIcon } from "./icons";
import { format as superscript } from "./SuperscriptButton.svelte";
const namedFormat = {
key,
name: tr.editingSubscript(),
show: true,
active: true,
format,
};
const { removeFormats } = editorToolbarContext.get();
removeFormats.update((formats) => [...formats, namedFormat]);
const { focusedInput } = noteEditorContext.get();
const surrounder = Surrounder.make();
let disabled: boolean;
$: if (editingInputIsRichText($focusedInput)) {
surrounder.richText = $focusedInput;
disabled = false;
} else {
surrounder.disable();
disabled = true;
}
function updateStateFromActiveInput(): Promise<boolean> {
return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(format);
async function updateStateFromActiveInput(): Promise<boolean> {
return disabled ? false : surrounder.isSurrounded(key);
}
function makeSub(): void {
surrounder.surround(format, [superscript]);
surrounder.surround(key, ["superscript"]);
}
const keyCombination = "Control+Shift+=";
let disabled: boolean;
onMount(() =>
singleCallback(
surrounder.active.subscribe((value) => (disabled = !value)),
surrounder.registerFormat(key, format),
),
);
</script>
<WithState
key="sub"
update={updateStateFromActiveInput}
let:state={active}
let:updateState
>
<WithState {key} update={updateStateFromActiveInput} let:state={active} let:updateState>
<IconButton
tooltip="{tr.editingSubscript()} ({getPlatformString(keyCombination)})"
{active}
@ -91,7 +82,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:click={(event) => {
makeSub();
updateState(event);
updateStateByKey("super", event);
updateStateByKey("superscript", event);
}}
>
{@html subscriptIcon}
@ -102,7 +93,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:action={(event) => {
makeSub();
updateState(event);
updateStateByKey("super", event);
updateStateByKey("superscript", event);
}}
/>
</WithState>

View File

@ -2,9 +2,21 @@
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">
<script lang="ts">
import { onMount } from "svelte";
import IconButton from "../../components/IconButton.svelte";
import Shortcut from "../../components/Shortcut.svelte";
import WithState from "../../components/WithState.svelte";
import { updateStateByKey } from "../../components/WithState.svelte";
import type { MatchType } from "../../domlib/surround";
import * as tr from "../../lib/ftl";
import { getPlatformString } from "../../lib/shortcuts";
import { removeStyleProperties } from "../../lib/styling";
import { singleCallback } from "../../lib/typing";
import { surrounder } from "../rich-text-input";
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import { superscriptIcon } from "./icons";
const surroundElement = document.createElement("sup");
@ -25,65 +37,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
}
const key = "superscript";
export const format = {
surroundElement,
matcher,
};
</script>
<script lang="ts">
import IconButton from "../../components/IconButton.svelte";
import Shortcut from "../../components/Shortcut.svelte";
import WithState from "../../components/WithState.svelte";
import { updateStateByKey } from "../../components/WithState.svelte";
import * as tr from "../../lib/ftl";
import { getPlatformString } from "../../lib/shortcuts";
import { context as noteEditorContext } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import { Surrounder } from "../surround";
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import { superscriptIcon } from "./icons";
import { format as subscript } from "./SubscriptButton.svelte";
const namedFormat = {
key,
name: tr.editingSuperscript(),
show: true,
active: true,
format,
};
const { removeFormats } = editorToolbarContext.get();
removeFormats.update((formats) => [...formats, namedFormat]);
const { focusedInput } = noteEditorContext.get();
const surrounder = Surrounder.make();
let disabled: boolean;
$: if (editingInputIsRichText($focusedInput)) {
surrounder.richText = $focusedInput;
disabled = false;
} else {
surrounder.disable();
disabled = true;
}
function updateStateFromActiveInput(): Promise<boolean> {
return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(format);
async function updateStateFromActiveInput(): Promise<boolean> {
return disabled ? false : surrounder.isSurrounded(key);
}
function makeSuper(): void {
surrounder.surround(format, [subscript]);
surrounder.surround(key, ["subscript"]);
}
const keyCombination = "Control+=";
let disabled: boolean;
onMount(() =>
singleCallback(
surrounder.active.subscribe((value) => (disabled = !value)),
surrounder.registerFormat(key, format),
),
);
</script>
<WithState
key="super"
update={updateStateFromActiveInput}
let:state={active}
let:updateState
>
<WithState {key} update={updateStateFromActiveInput} let:state={active} let:updateState>
<IconButton
tooltip="{tr.editingSuperscript()} ({getPlatformString(keyCombination)})"
{active}
@ -91,7 +82,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:click={(event) => {
makeSuper();
updateState(event);
updateStateByKey("sub", event);
updateStateByKey("subscript", event);
}}
>
{@html superscriptIcon}
@ -102,7 +93,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:action={(event) => {
makeSuper();
updateState(event);
updateStateByKey("sub", event);
updateStateByKey("subscript", event);
}}
/>
</WithState>

View File

@ -3,23 +3,19 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { onMount } from "svelte";
import IconButton from "../../components/IconButton.svelte";
import Shortcut from "../../components/Shortcut.svelte";
import type {
FormattingNode,
MatchType,
SurroundFormat,
} from "../../domlib/surround";
import type { FormattingNode, MatchType } from "../../domlib/surround";
import { bridgeCommand } from "../../lib/bridgecommand";
import * as tr from "../../lib/ftl";
import { getPlatformString } from "../../lib/shortcuts";
import { removeStyleProperties } from "../../lib/styling";
import { singleCallback } from "../../lib/typing";
import { withFontColor } from "../helpers";
import { context as noteEditorContext } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import { Surrounder } from "../surround";
import { surrounder } from "../rich-text-input";
import ColorPicker from "./ColorPicker.svelte";
import type { RemoveFormat } from "./EditorToolbar.svelte";
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import { arrowIcon, textColorIcon } from "./icons";
import WithColorHelper from "./WithColorHelper.svelte";
@ -93,40 +89,39 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return true;
}
const format: SurroundFormat<string> = {
const key = "textColor";
const format = {
matcher,
merger,
formatter,
};
const namedFormat: RemoveFormat<string> = {
const namedFormat = {
key,
name: tr.editingTextColor(),
show: true,
active: true,
format,
};
const { removeFormats } = editorToolbarContext.get();
removeFormats.update((formats) => [...formats, namedFormat]);
const { focusedInput } = noteEditorContext.get();
const surrounder = Surrounder.make();
let disabled: boolean;
$: if (editingInputIsRichText($focusedInput)) {
surrounder.richText = $focusedInput;
disabled = false;
} else {
surrounder.disable();
disabled = true;
}
function setTextColor(): void {
surrounder.overwriteSurround(format);
surrounder.overwriteSurround(key);
}
const setCombination = "F7";
const pickCombination = "F8";
let disabled: boolean;
onMount(() =>
singleCallback(
surrounder.active.subscribe((value) => (disabled = !value)),
surrounder.registerFormat(key, format),
),
);
</script>
<WithColorHelper {color} let:colorHelperIcon let:setColor>

View File

@ -3,15 +3,16 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { onMount } from "svelte";
import IconButton from "../../components/IconButton.svelte";
import Shortcut from "../../components/Shortcut.svelte";
import WithState from "../../components/WithState.svelte";
import type { MatchType } from "../../domlib/surround";
import * as tr from "../../lib/ftl";
import { getPlatformString } from "../../lib/shortcuts";
import { context as noteEditorContext } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import { Surrounder } from "../surround";
import { singleCallback } from "../../lib/typing";
import { surrounder } from "../rich-text-input";
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
import { underlineIcon } from "./icons";
@ -23,7 +24,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
}
const clearer = () => false;
function clearer() {
return false;
}
const key = "underline";
const format = {
surroundElement,
@ -32,44 +37,36 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
};
const namedFormat = {
key,
name: tr.editingUnderlineText(),
show: true,
active: true,
format,
};
const { removeFormats } = editorToolbarContext.get();
removeFormats.update((formats) => [...formats, namedFormat]);
const { focusedInput } = noteEditorContext.get();
const surrounder = Surrounder.make();
let disabled: boolean;
$: if (editingInputIsRichText($focusedInput)) {
surrounder.richText = $focusedInput;
disabled = false;
} else {
surrounder.disable();
disabled = true;
}
function updateStateFromActiveInput(): Promise<boolean> {
return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(format);
async function updateStateFromActiveInput(): Promise<boolean> {
return disabled ? false : surrounder.isSurrounded(key);
}
function makeUnderline(): void {
surrounder.surround(format);
surrounder.surround(key);
}
const keyCombination = "Control+U";
let disabled: boolean;
onMount(() =>
singleCallback(
surrounder.active.subscribe((value) => (disabled = !value)),
surrounder.registerFormat(key, format),
),
);
</script>
<WithState
key="underline"
update={updateStateFromActiveInput}
let:state={active}
let:updateState
>
<WithState {key} update={updateStateFromActiveInput} let:state={active} let:updateState>
<IconButton
tooltip="{tr.editingUnderlineText()} ({getPlatformString(keyCombination)})"
{active}

View File

@ -1,6 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import EditorToolbar from "./EditorToolbar.svelte";
export type { EditorToolbarAPI } from "./EditorToolbar.svelte";
export { default as EditorToolbar, editorToolbar } from "./EditorToolbar.svelte";
export { default as ClozeButtons } from "./EditorToolbar.svelte";
export default EditorToolbar;
export { editorToolbar } from "./EditorToolbar.svelte";

View File

@ -1,4 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export { default as ImageHandle } from "./ImageHandle.svelte";
import ImageHandle from "./ImageHandle.svelte";
export default ImageHandle;

View File

@ -1,4 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export { default as MathjaxHandle } from "./MathjaxHandle.svelte";
import MathjaxHandle from "./MathjaxHandle.svelte";
export default MathjaxHandle;

View File

@ -1,8 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import PlainTextInput from "./PlainTextInput.svelte";
export type { PlainTextInputAPI } from "./PlainTextInput.svelte";
export {
parsingInstructions,
default as PlainTextInput,
} from "./PlainTextInput.svelte";
export default PlainTextInput;
export * from "./PlainTextInput.svelte";

View File

@ -6,9 +6,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { ContentEditableAPI } from "../../editable/ContentEditable.svelte";
import type { InputHandlerAPI } from "../../sveltelib/input-handler";
import type { EditingInputAPI, FocusableInputAPI } from "../EditingArea.svelte";
import type { SurroundedAPI } from "../surround";
import type CustomStyles from "./CustomStyles.svelte";
export interface RichTextInputAPI extends EditingInputAPI {
export interface RichTextInputAPI extends EditingInputAPI, SurroundedAPI {
name: "rich-text";
/** This is the contentEditable anki-editable element */
element: Promise<HTMLElement>;
@ -21,7 +22,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
customStyles: Promise<CustomStyles>;
}
export function editingInputIsRichText(
function editingInputIsRichText(
editingInput: EditingInputAPI | null,
): editingInput is RichTextInputAPI {
return editingInput?.name === "rich-text";
@ -30,20 +31,28 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { registerPackage } from "../../lib/runtime-require";
import contextProperty from "../../sveltelib/context-property";
import lifecycleHooks from "../../sveltelib/lifecycle-hooks";
import { Surrounder } from "../surround";
const key = Symbol("richText");
const [context, setContextProperty] = contextProperty<RichTextInputAPI>(key);
const [globalInputHandler, setupGlobalInputHandler] = useInputHandler();
const [lifecycle, instances, setupLifecycleHooks] =
lifecycleHooks<RichTextInputAPI>();
const surrounder = Surrounder.make();
registerPackage("anki/RichTextInput", {
context,
surrounder,
lifecycle,
instances,
});
export { context, globalInputHandler as inputHandler };
export {
context,
editingInputIsRichText,
globalInputHandler as inputHandler,
surrounder,
};
</script>
<script lang="ts">
@ -52,12 +61,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { placeCaretAfterContent } from "../../domlib/place-caret";
import ContentEditable from "../../editable/ContentEditable.svelte";
import {
descriptionKey,
directionKey,
fontFamilyKey,
fontSizeKey,
} from "../../lib/context-keys";
import { directionKey, fontFamilyKey, fontSizeKey } from "../../lib/context-keys";
import { promiseWithResolver } from "../../lib/promise";
import { singleCallback } from "../../lib/typing";
import useDOMMirror from "../../sveltelib/dom-mirror";
@ -75,7 +79,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const { focusedInput } = noteEditorContext.get();
const { content, editingInputs } = editingAreaContext.get();
const description = getContext<Readable<string>>(descriptionKey);
const fontFamily = getContext<Readable<string>>(fontFamilyKey);
const fontSize = getContext<Readable<number>>(fontSizeKey);
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey);
@ -173,6 +176,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function setFocus(): void {
$focusedInput = api;
surrounder.enable(api);
// We do not unset focusedInput here.
// If we did, UI components for the input would react the store
@ -180,6 +184,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
// field right away.
}
function removeFocus(): void {
surrounder.disable();
}
$: pushUpdate(!hidden);
onMount(() => {
@ -200,18 +208,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setupLifecycleHooks(api);
</script>
<div class="rich-text-input" on:focusin={setFocus} {hidden}>
{#if $content.length === 0}
<div
class="rich-text-placeholder"
style:font-family={$fontFamily}
style:font-size={$fontSize + "px"}
style:direction={$direction}
>
{$description}
</div>
{/if}
<div class="rich-text-input" {hidden} on:focusin={setFocus} on:focusout={removeFocus}>
<RichTextStyles
color={$pageTheme.isDark ? "white" : "black"}
fontFamily={$fontFamily}
@ -243,19 +240,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss">
.rich-text-input {
position: relative;
margin: 6px;
padding: 6px;
}
.rich-text-placeholder {
position: absolute;
color: var(--disabled);
/* Adopts same size as the content editable element */
width: 100%;
height: 100%;
/* Keep text on single line and hide overflow */
white-space: nowrap;
overflow-x: hidden;
text-overflow: ellipsis;
.hidden {
display: none;
}
</style>

View File

@ -1,9 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { default as RichTextInput } from "./RichTextInput.svelte";
export type { RichTextInputAPI } from "./RichTextInput.svelte";
export {
context,
editingInputIsRichText,
default as RichTextInput,
} from "./RichTextInput.svelte";
export default RichTextInput;
export * from "./RichTextInput.svelte";

View File

@ -1,7 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { get } from "svelte/store";
import type { Writable } from "svelte/store";
import { get, writable } from "svelte/store";
import type { Matcher } from "../domlib/find-above";
import { findClosest } from "../domlib/find-above";
@ -10,7 +11,11 @@ import { boolMatcher, reformat, surround, unsurround } from "../domlib/surround"
import { getRange, getSelection } from "../lib/cross-browser";
import { registerPackage } from "../lib/runtime-require";
import type { TriggerItem } from "../sveltelib/handler-list";
import type { RichTextInputAPI } from "./rich-text-input";
import type { InputHandlerAPI } from "../sveltelib/input-handler";
function isValid<T>(value: T | undefined): value is T {
return Boolean(value);
}
function isSurroundedInner(
range: AbstractRange,
@ -57,35 +62,54 @@ function removeFormats(
return surroundRange;
}
export class Surrounder {
static make(): Surrounder {
export interface SurroundedAPI {
element: Promise<HTMLElement>;
inputHandler: InputHandlerAPI;
}
export class Surrounder<T = unknown> {
static make<T>(): Surrounder<T> {
return new Surrounder();
}
private api: RichTextInputAPI | null = null;
private trigger: TriggerItem<{ event: InputEvent; text: Text }> | null = null;
private api: SurroundedAPI | null = null;
private triggers: Map<string, TriggerItem<{ event: InputEvent; text: Text }>> =
new Map();
set richText(api: RichTextInputAPI) {
active: Writable<boolean> = writable(false);
enable(api: SurroundedAPI): void {
this.api = api;
this.trigger = api.inputHandler.insertText.trigger({ once: true });
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
* exception. Make sure to set the rich text before trying to use them again.
* exception. Make sure to set the input before trying to use them again.
*/
disable(): void {
this.api = null;
this.trigger?.off();
this.trigger = null;
this.active.set(false);
for (const [key, trigger] of this.triggers) {
trigger.off();
this.triggers.delete(key);
}
}
private async _assert_base(): Promise<HTMLElement> {
if (!this.api) {
throw new Error("No rich text set");
throw new Error("Surrounder: No input set");
}
return await this.api.element;
return this.api.element;
}
private _toggleTrigger<T>(
@ -93,12 +117,13 @@ export class Surrounder {
selection: Selection,
matcher: Matcher,
format: SurroundFormat<T>,
trigger: TriggerItem<{ event: InputEvent; text: Text }>,
exclusive: SurroundFormat<T>[] = [],
): void {
if (get(this.trigger!.active)) {
this.trigger!.off();
if (get(trigger.active)) {
trigger.off();
} else {
this.trigger!.on(async ({ text }) => {
trigger.on(async ({ text }) => {
const range = new Range();
range.selectNode(text);
@ -114,9 +139,10 @@ export class Surrounder {
base: HTMLElement,
selection: Selection,
format: SurroundFormat<T>,
trigger: TriggerItem<{ event: InputEvent; text: Text }>,
exclusive: SurroundFormat<T>[] = [],
): void {
this.trigger!.on(async ({ text }) => {
trigger.on(async ({ text }) => {
const range = new Range();
range.selectNode(text);
@ -132,68 +158,121 @@ export class Surrounder {
base: HTMLElement,
selection: Selection,
remove: SurroundFormat<T>[],
triggers: TriggerItem<{ event: InputEvent; text: Text }>[],
reformat: SurroundFormat<T>[] = [],
): void {
this.trigger!.on(async ({ text }) => {
const range = new Range();
range.selectNode(text);
triggers.map((trigger) =>
trigger.on(async ({ text }) => {
const range = new Range();
range.selectNode(text);
const clearedRange = removeFormats(range, base, remove, reformat);
selection.removeAllRanges();
selection.addRange(clearedRange);
selection.collapseToEnd();
});
const clearedRange = removeFormats(range, base, remove, reformat);
selection.removeAllRanges();
selection.addRange(clearedRange);
selection.collapseToEnd();
}),
);
}
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);
}
/**
* Use the surround command on the current range of the RichTextInput.
* Check if a surround format under the given key is registered.
*/
hasFormat(key: string): boolean {
return this.formats.has(key);
}
/**
* Use the surround command on the current range of the input.
* If the range is already surrounded, it will unsurround instead.
*/
async surround<T>(
format: SurroundFormat<T>,
exclusive: SurroundFormat<T>[] = [],
): Promise<void> {
async surround(formatName: string, exclusiveNames: string[] = []): Promise<void> {
const base = await this._assert_base();
const selection = getSelection(base)!;
const range = getRange(selection);
const matcher = boolMatcher(format);
const format = this.formats.get(formatName);
const trigger = this.triggers.get(formatName);
if (!range) {
if (!format || !range || !trigger) {
return;
}
const matcher = boolMatcher(format);
const exclusives = exclusiveNames
.map((name) => this.formats.get(name))
.filter(isValid);
if (range.collapsed) {
return this._toggleTrigger(base, selection, matcher, format, exclusive);
return this._toggleTrigger(
base,
selection,
matcher,
format,
trigger,
exclusives,
);
}
const clearedRange = removeFormats(range, base, exclusive);
const clearedRange = removeFormats(range, base, exclusives);
const matches = isSurroundedInner(clearedRange, base, matcher);
surroundAndSelect(matches, clearedRange, base, format, selection);
}
/**
* Use the surround command on the current range of the RichTextInput.
* Use the surround command on the current range of the input.
* If the range is already surrounded, it will overwrite the format.
* This might be better suited if the surrounding is parameterized (like
* text color).
*/
async overwriteSurround<T>(
format: SurroundFormat<T>,
exclusive: SurroundFormat<T>[] = [],
async overwriteSurround(
formatName: string,
exclusiveNames: string[] = [],
): Promise<void> {
const base = await this._assert_base();
const selection = getSelection(base)!;
const range = getRange(selection);
const format = this.formats.get(formatName);
const trigger = this.triggers.get(formatName);
if (!range) {
if (!format || !range || !trigger) {
return;
}
const exclusives = exclusiveNames
.map((name) => this.formats.get(name))
.filter(isValid);
if (range.collapsed) {
return this._toggleTriggerOverwrite(base, selection, format, exclusive);
return this._toggleTriggerOverwrite(
base,
selection,
format,
trigger,
exclusives,
);
}
const clearedRange = removeFormats(range, base, exclusive);
const clearedRange = removeFormats(range, base, exclusives);
const surroundedRange = surround(clearedRange, base, format);
selection.removeAllRanges();
selection.addRange(surroundedRange);
@ -205,26 +284,25 @@ export class Surrounder {
* provided format, OR if a surround trigger is active (surround on next
* text insert).
*/
async isSurrounded<T>(format: SurroundFormat<T>): Promise<boolean> {
async isSurrounded(formatName: string): Promise<boolean> {
const base = await this._assert_base();
const selection = getSelection(base)!;
const range = getRange(selection);
const format = this.formats.get(formatName);
const trigger = this.triggers.get(formatName);
if (!range) {
if (!format || !range || !trigger) {
return false;
}
const isSurrounded = isSurroundedInner(range, base, boolMatcher(format));
return get(this.trigger!.active) ? !isSurrounded : isSurrounded;
return get(trigger.active) ? !isSurrounded : isSurrounded;
}
/**
* Clear/Reformat the provided formats in the current range.
*/
async remove<T>(
formats: SurroundFormat<T>[],
reformats: SurroundFormat<T>[] = [],
): Promise<void> {
async remove(formatNames: string[], reformatNames: string[] = []): Promise<void> {
const base = await this._assert_base();
const selection = getSelection(base)!;
const range = getRange(selection);
@ -233,8 +311,26 @@ export class Surrounder {
return;
}
const formats = formatNames
.map((name) => this.formats.get(name))
.filter(isValid);
const triggers = formatNames
.map((name) => this.triggers.get(name))
.filter(isValid);
const reformats = reformatNames
.map((name) => this.formats.get(name))
.filter(isValid);
if (range.collapsed) {
return this._toggleTriggerRemove(base, selection, formats, reformats);
return this._toggleTriggerRemove(
base,
selection,
formats,
triggers,
reformats,
);
}
const surroundedRange = removeFormats(range, base, formats, reformats);