0c81bbce04
* Fix field not being restored after Undo, if field also has focus * Execute moveCaretToEnd after undoing a change - Otherwise the caret might be placed in seemingly random positions * Fix wording of comments * Remove autofocus functionality of EditingArea - instead await a tick in focusField - We used the autofocus prop for the initial focus setting when opening the editor. However it seems that awaiting tick in focusField also does the trick.
292 lines
9.0 KiB
Svelte
292 lines
9.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 { ContentEditableAPI } from "../../editable/ContentEditable.svelte";
|
|
import contextProperty from "../../sveltelib/context-property";
|
|
import type {
|
|
OnInputCallback,
|
|
OnInsertCallback,
|
|
Trigger,
|
|
} from "../../sveltelib/input-manager";
|
|
import { pageTheme } from "../../sveltelib/theme";
|
|
import type { EditingInputAPI } from "../EditingArea.svelte";
|
|
import type CustomStyles from "./CustomStyles.svelte";
|
|
|
|
export interface RichTextInputAPI extends EditingInputAPI, ContentEditableAPI {
|
|
name: "rich-text";
|
|
shadowRoot: Promise<ShadowRoot>;
|
|
element: Promise<HTMLElement>;
|
|
moveCaretToEnd(): void;
|
|
toggle(): boolean;
|
|
preventResubscription(): () => void;
|
|
getTriggerOnNextInsert(): Trigger<OnInsertCallback>;
|
|
getTriggerOnInput(): Trigger<OnInputCallback>;
|
|
getTriggerAfterInput(): Trigger<OnInputCallback>;
|
|
}
|
|
|
|
export function editingInputIsRichText(
|
|
editingInput: EditingInputAPI | null,
|
|
): editingInput is RichTextInputAPI {
|
|
return editingInput?.name === "rich-text";
|
|
}
|
|
|
|
export interface RichTextInputContextAPI {
|
|
styles: CustomStyles;
|
|
container: HTMLElement;
|
|
api: RichTextInputAPI;
|
|
}
|
|
|
|
const key = Symbol("richText");
|
|
const [context, setContextProperty] = contextProperty<RichTextInputContextAPI>(key);
|
|
|
|
import getInputManager from "../../sveltelib/input-manager";
|
|
import getDOMMirror from "../../sveltelib/mirror-dom";
|
|
|
|
const {
|
|
manager: globalInputManager,
|
|
getTriggerAfterInput,
|
|
getTriggerOnInput,
|
|
getTriggerOnNextInsert,
|
|
} = getInputManager();
|
|
|
|
export { context, getTriggerAfterInput, getTriggerOnInput, getTriggerOnNextInsert };
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import { getAllContexts, onMount } from "svelte";
|
|
|
|
import { placeCaretAfterContent } from "../../domlib/place-caret";
|
|
import ContentEditable from "../../editable/ContentEditable.svelte";
|
|
import type { DecoratedElement } from "../../editable/decorated";
|
|
import { bridgeCommand } from "../../lib/bridgecommand";
|
|
import {
|
|
fragmentToString,
|
|
nodeContainsInlineContent,
|
|
nodeIsElement,
|
|
} from "../../lib/dom";
|
|
import { on } from "../../lib/events";
|
|
import { promiseWithResolver } from "../../lib/promise";
|
|
import { nodeStore } from "../../sveltelib/node-store";
|
|
import { context as decoratedElementsContext } from "../DecoratedElements.svelte";
|
|
import { context as editingAreaContext } from "../EditingArea.svelte";
|
|
import RichTextStyles from "./RichTextStyles.svelte";
|
|
import SetContext from "./SetContext.svelte";
|
|
|
|
export let hidden: boolean;
|
|
|
|
const { content, editingInputs } = editingAreaContext.get();
|
|
const decoratedElements = decoratedElementsContext.get();
|
|
|
|
const range = document.createRange();
|
|
|
|
function normalizeFragment(fragment: DocumentFragment): void {
|
|
fragment.normalize();
|
|
|
|
for (const decorated of decoratedElements) {
|
|
for (const element of fragment.querySelectorAll(
|
|
decorated.tagName,
|
|
) as NodeListOf<DecoratedElement>) {
|
|
element.undecorate();
|
|
}
|
|
}
|
|
}
|
|
|
|
const nodes = nodeStore<DocumentFragment>(undefined, normalizeFragment);
|
|
|
|
function adjustInputHTML(html: string): string {
|
|
for (const component of decoratedElements) {
|
|
html = component.toUndecorated(html);
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
function adjustInputFragment(fragment: DocumentFragment): void {
|
|
if (nodeContainsInlineContent(fragment)) {
|
|
fragment.appendChild(document.createElement("br"));
|
|
}
|
|
}
|
|
|
|
function writeFromEditingArea(html: string): void {
|
|
/* we need createContextualFragment so that customElements are initialized */
|
|
const fragment = range.createContextualFragment(adjustInputHTML(html));
|
|
adjustInputFragment(fragment);
|
|
nodes.setUnprocessed(fragment);
|
|
}
|
|
|
|
function adjustOutputFragment(fragment: DocumentFragment): void {
|
|
if (
|
|
fragment.hasChildNodes() &&
|
|
nodeIsElement(fragment.lastChild!) &&
|
|
nodeContainsInlineContent(fragment) &&
|
|
fragment.lastChild!.tagName === "BR"
|
|
) {
|
|
fragment.lastChild!.remove();
|
|
}
|
|
}
|
|
|
|
function adjustOutputHTML(html: string): string {
|
|
for (const component of decoratedElements) {
|
|
html = component.toStored(html);
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
function writeToEditingArea(fragment: DocumentFragment): void {
|
|
const clone = document.importNode(fragment, true);
|
|
adjustOutputFragment(clone);
|
|
|
|
const output = adjustOutputHTML(fragmentToString(clone));
|
|
content.set(output);
|
|
}
|
|
|
|
const [shadowPromise, shadowResolve] = promiseWithResolver<ShadowRoot>();
|
|
|
|
function attachShadow(element: Element): void {
|
|
shadowResolve(element.attachShadow({ mode: "open" }));
|
|
}
|
|
|
|
const [richTextPromise, richTextResolve] = promiseWithResolver<HTMLElement>();
|
|
|
|
function resolve(richTextInput: HTMLElement): { destroy: () => void } {
|
|
function onPaste(event: Event): void {
|
|
event.preventDefault();
|
|
bridgeCommand("paste");
|
|
}
|
|
|
|
function onCutOrCopy(): void {
|
|
bridgeCommand("cutOrCopy");
|
|
}
|
|
|
|
const removePaste = on(richTextInput, "paste", onPaste);
|
|
const removeCopy = on(richTextInput, "copy", onCutOrCopy);
|
|
const removeCut = on(richTextInput, "cut", onCutOrCopy);
|
|
richTextResolve(richTextInput);
|
|
|
|
return {
|
|
destroy() {
|
|
removePaste();
|
|
removeCopy();
|
|
removeCut();
|
|
},
|
|
};
|
|
}
|
|
|
|
const { mirror, preventResubscription } = getDOMMirror();
|
|
const localInputManager = getInputManager();
|
|
|
|
function moveCaretToEnd() {
|
|
richTextPromise.then(placeCaretAfterContent);
|
|
}
|
|
|
|
export const api = {
|
|
name: "rich-text",
|
|
shadowRoot: shadowPromise,
|
|
element: richTextPromise,
|
|
focus() {
|
|
richTextPromise.then((richText) => {
|
|
richText.focus();
|
|
});
|
|
},
|
|
refocus() {
|
|
richTextPromise.then((richText) => {
|
|
richText.blur();
|
|
richText.focus();
|
|
moveCaretToEnd();
|
|
});
|
|
},
|
|
focusable: !hidden,
|
|
toggle(): boolean {
|
|
hidden = !hidden;
|
|
return hidden;
|
|
},
|
|
moveCaretToEnd,
|
|
preventResubscription,
|
|
getTriggerOnNextInsert: localInputManager.getTriggerOnNextInsert,
|
|
getTriggerOnInput: localInputManager.getTriggerOnInput,
|
|
getTriggerAfterInput: localInputManager.getTriggerAfterInput,
|
|
} as RichTextInputAPI;
|
|
|
|
const allContexts = getAllContexts();
|
|
|
|
function attachContentEditable(element: Element, { stylesDidLoad }): void {
|
|
stylesDidLoad.then(
|
|
() =>
|
|
new ContentEditable({
|
|
target: element.shadowRoot!,
|
|
props: {
|
|
nodes,
|
|
resolve,
|
|
mirrors: [mirror],
|
|
managers: [globalInputManager, localInputManager.manager],
|
|
api,
|
|
},
|
|
context: allContexts,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function pushUpdate(): void {
|
|
api.focusable = !hidden;
|
|
$editingInputs = $editingInputs;
|
|
}
|
|
|
|
$: {
|
|
hidden;
|
|
pushUpdate();
|
|
}
|
|
|
|
onMount(() => {
|
|
$editingInputs.push(api);
|
|
$editingInputs = $editingInputs;
|
|
|
|
const unsubscribeFromEditingArea = content.subscribe(writeFromEditingArea);
|
|
const unsubscribeToEditingArea = nodes.subscribe(writeToEditingArea);
|
|
|
|
return () => {
|
|
unsubscribeFromEditingArea();
|
|
unsubscribeToEditingArea();
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="rich-text-input">
|
|
<RichTextStyles
|
|
color={$pageTheme.isDark ? "white" : "black"}
|
|
let:attachToShadow={attachStyles}
|
|
let:promise={stylesPromise}
|
|
let:stylesDidLoad
|
|
>
|
|
<div
|
|
class="rich-text-editable"
|
|
class:hidden
|
|
class:night-mode={$pageTheme.isDark}
|
|
use:attachShadow
|
|
use:attachStyles
|
|
use:attachContentEditable={{ stylesDidLoad }}
|
|
on:focusin
|
|
on:focusout
|
|
/>
|
|
|
|
<div class="rich-text-widgets">
|
|
{#await Promise.all( [richTextPromise, stylesPromise], ) then [container, styles]}
|
|
<SetContext
|
|
setter={setContextProperty}
|
|
value={{ container, styles, api }}
|
|
>
|
|
<slot />
|
|
</SetContext>
|
|
{/await}
|
|
</div>
|
|
</RichTextStyles>
|
|
</div>
|
|
|
|
<style lang="scss">
|
|
.hidden {
|
|
display: none;
|
|
}
|
|
</style>
|