Translate Editor entirely to Svelte (#1403)

* Translate editor to Svelte

Make editor fields grid rather than flexbox

Refactor ButtonToolbar margins

Remove remaining svelte.d.ts symlinks

Implement saveNow

Fix text surrounding

Remove HTML editor button

Clean up some empty files

Add visual for new field state badges

* Adds new IconConstrain.svelte to generalize the icon handling for
IconButton and Badge

Implement sticky functionality again

Enable Editable and Codable field state badges

Add shortcuts to FieldState badges

Add Shift+F9 shortcut back

Add inline padding back to editor fields, tag editor and toolbar

Make Editable and Codable only "visually hidden"

This way they are still updated in the background
Otherwise reshowing them will always start them up empty

Make empty editing area focusable

Start with moving fieldsKey and currentFieldKey to context.ts

Fix Codable being wrong size when opening for first time

Add back drag'n'drop

Make ButtonItem display: contents again

* This will break the gap between ButtonGroup items, however once we
  have a newer Chromium version we should use CSS gap property anyway

Fix most of typing issues

Use --label-color background color LabelContainer

Add back red color for dupes

Generalize the editor toolbar in the multiroot editor to widgets

Implement Notification.svelte for showing cloze hints

Add colorful icon to notification

Hook up Editable to EditingArea

Move EditingArea into EditorField

Include editorField in editor/context

Fix rebasing issues

Uniformly use SvelteComponentTyped

Take LabelContainer out of EditingArea

Use mirror-dom and node-store to export editable content

Fix editable update mechanism

Prepare passing the editing inputs as slots

Pass in editing inputs as slots

Use codable options again in codemirror

Delete editor/lib.ts

Remove CodableAdapter, Use more generic CodeMirror component

Fix clicking LabelContainer to focus

Use prettier

Rename Editable to ContentEditable

Fix writing Mathjax from Codable to Editable

Correctly adjust output HTML from editable

Refactor EditableStyles out of EditableContainer

Pass Image and Mathjax Handle via slots to Editable

Make Editable add its editingInputApi

Make Editable hideable

Fix font size not being set correctly

Refactor both fieldFocused and focusInCodable to focusInEditable

Fix focusIfField

Bring back $activeInput

Fix ClozeButton

Remove signifyCustomInput

Refactor MathjaxHandle

Refactor out some logic into store-subscribe

Fix Mathjax editor

Use focusTrap instead of focusing div

Delegate focus back to editingInput when refocusing focusTrap

Elegantly move focus between editing inputs when closing/opening

Make Codable tabbable

Automatically move caret to end on editable and codable

+ remove from editingInput api

Fix ButtonDropdown having two rows and missing button margins

Make svelte_check and eslint pass

Satisfy editor svelte_check

Save field updates to db again

Await editable styles before mounting content editable

Remove unused import from OldEditorAdapter

Add copyright header to OldEditorAdapter

Update button active state from contenteditable

* Use activateStickyShortcuts after waiting for noteEditorPromise

* Set fields via stores, make tags correctly set

* Add explaining comment to setFields

* Fix ClozeButton

* Send focus and blur events again

* Fix Codable not correctly updating on blur with invalid HTML

* Remove old code for special Enter behavior in tags

* Do not use logical properties for ButtonToolbar margins

* Remove getCurrentField

Instead use noteEditor->currentField or noteEditor->activeInput

* Remove Extensible type

* Use context-property for NoteEditor, EditorField and EditingArea

* Rename parameter in mirror-dom.allowResubscription

* Fix cutOrCopy

* Refactor context.ts into the individual components

* Move focusing of editingArea up to editorField

* Rename promiseResolve -> promiseWithResolver

* Rename Editable->RichTextInput and Codable->PlainTextInput

* Remove now unnecessary type assertion for `getNoteEditor` and `getEditingArea`

* Refocus field after adding, so subscription to editing area is refreshed
This commit is contained in:
Henrik Giesel 2021-10-18 14:01:15 +02:00 committed by GitHub
parent cbc358ff0b
commit c2768e2188
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
105 changed files with 2887 additions and 2117 deletions

View File

@ -58,8 +58,8 @@
"@mdi/svg": "^6.1.95",
"@popperjs/core": "^2.9.2",
"@types/lodash-es": "^4.17.4",
"bootstrap": "=5.0.2",
"@types/marked": "^3.0.1",
"bootstrap": "=5.0.2",
"bootstrap-icons": "^1.4.0",
"codemirror": "^5.63.1",
"css-browser-selector": "^0.6.5",

View File

@ -60,7 +60,7 @@ class AddCards(QDialog):
def setupEditor(self) -> None:
self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self, True)
self.editor.web.eval("activateStickyShortcuts();")
self.editor.web.eval("noteEditorPromise.then(() => activateStickyShortcuts());")
def setup_choosers(self) -> None:
defaults = self.col.defaults_for_adding(
@ -264,16 +264,6 @@ class AddCards(QDialog):
return True
def keyPressEvent(self, evt: QKeyEvent) -> None:
"Show answer on RET or register answer."
if (
evt.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return)
and self.editor.tags.hasFocus()
):
evt.accept()
return
return QDialog.keyPressEvent(self, evt)
def reject(self) -> None:
self.ifCanClose(self._reject)

View File

@ -78,15 +78,6 @@ audio = (
"webm",
)
_html = """
<div id="fields"></div>
<div id="dupes" class="d-none">
<a href="#" onclick="pycmd('dupes');return false;">%s</a>
</div>
<div id="cloze-hint" class="d-none"></div>
<div id="tag-editor-anchor" class="d-none"></div>
"""
class Editor:
"""The screen that embeds an editing widget should listen for changes via
@ -135,14 +126,9 @@ class Editor:
# then load page
self.web.stdHtml(
_html % tr.editing_show_duplicates(),
css=[
"css/editor.css",
],
js=[
"js/vendor/jquery.min.js",
"js/editor.js",
],
"", # % tr.editing_show_duplicates(),
css=["css/editor.css"],
js=["js/editor.js"],
context=self,
default_css=True,
)
@ -506,7 +492,9 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
js += " setSticky(%s);" % json.dumps(sticky)
js = gui_hooks.editor_will_load_note(js, self.note, self)
self.web.evalWithCallback(js, oncallback)
self.web.evalWithCallback(
f"noteEditorPromise.then(() => {{ {js} }})", oncallback
)
def _save_current_note(self) -> None:
"Call after note is updated with data from webview."

View File

@ -31,8 +31,7 @@ $utilities: (
flex-basis: 75%;
}
body,
html {
* {
overscroll-behavior: none;
}

View File

@ -0,0 +1,38 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
export let top: boolean = false;
export let bottom: boolean = false;
export let left: boolean = false;
export let right: boolean = false;
</script>
<div class="absolute" class:top class:bottom class:left class:right>
<slot />
</div>
<style lang="scss">
.absolute {
position: absolute;
margin: var(--margin, 0);
z-index: 20;
}
.top {
top: 0;
}
.bottom {
bottom: 0;
}
.left {
left: 0;
}
.right {
right: 0;
}
</style>

View File

@ -3,6 +3,7 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import IconConstrain from "./IconConstrain.svelte";
import type { DropdownProps } from "./dropdown";
import { dropdownKey } from "./context-keys";
import { onMount, createEventDispatcher, getContext } from "svelte";
@ -11,6 +12,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export { className as class };
export let tooltip: string | undefined = undefined;
export let iconSize = 100;
export let widthMultiplier = 1;
export let flipX = false;
const dispatch = createEventDispatcher();
let spanRef: HTMLSpanElement;
@ -25,27 +30,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<span
bind:this={spanRef}
title={tooltip}
class={`badge ${className}`}
class="badge {className}"
class:dropdown-toggle={dropdownProps.dropdown}
{...dropdownProps}
on:click
on:mouseenter
on:mouseleave
>
<slot />
<IconConstrain {iconSize} {widthMultiplier} {flipX}>
<slot />
</IconConstrain>
</span>
<style>
.badge {
color: inherit;
color: var(--badge-color, inherit);
}
.dropdown-toggle::after {
display: none;
}
span :global(svg) {
border-radius: inherit;
vertical-align: var(--badge-align, -0.125rem);
}
</style>

View File

@ -23,7 +23,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
wrap={false}
{api}
>
<div on:mousedown|preventDefault|stopPropagation>
<div on:mousedown|preventDefault|stopPropagation on:click>
<slot />
</div>
</ButtonToolbar>

View File

@ -111,9 +111,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss">
div {
display: flex;
flex-direction: row;
flex-wrap: var(--buttons-wrap);
padding: calc(var(--buttons-size) / 10);
margin: 0;
}
</style>

View File

@ -3,7 +3,6 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import WithTheming from "./WithTheming.svelte";
import Detachable from "./Detachable.svelte";
import type { ButtonRegistration } from "./buttons";
@ -58,9 +57,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
</script>
<!-- div in WithTheming is necessary to preserve item position -->
<WithTheming {id} {style}>
<!-- div is necessary to preserve item position -->
<div {id} {style}>
<Detachable {detached}>
<slot />
</Detachable>
</WithTheming>
</div>

View File

@ -80,7 +80,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div
bind:this={buttonToolbarRef}
{id}
class="btn-toolbar container wrap-variable {className}"
class="button-toolbar btn-toolbar {className}"
class:nightMode
{style}
role="toolbar"
@ -101,7 +101,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
@include scrollbar.night-mode;
}
.wrap-variable {
.button-toolbar {
flex-wrap: var(--buttons-wrap);
> :global(*) {
/* TODO replace with gap once available */
margin-right: 0.15rem;
margin-bottom: 0.15rem;
}
}
</style>

View File

@ -3,6 +3,7 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import IconConstrain from "./IconConstrain.svelte";
import { getContext, onMount, createEventDispatcher } from "svelte";
import { nightModeKey, dropdownKey } from "./context-keys";
import type { DropdownProps } from "./dropdown";
@ -16,9 +17,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let disabled = false;
export let tabbable = false;
export let iconSize: number = 75;
export let widthMultiplier: number = 1;
export let flipX: boolean = false;
export let iconSize = 75;
export let widthMultiplier = 1;
export let flipX = false;
let buttonRef: HTMLButtonElement;
@ -32,12 +33,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<button
bind:this={buttonRef}
{id}
class={`btn ${className}`}
class="btn {className}"
class:active
class:dropdown-toggle={dropdownProps.dropdown}
class:btn-day={!nightMode}
class:btn-night={nightMode}
style={`--icon-size: ${iconSize}%`}
title={tooltip}
{...dropdownProps}
{disabled}
@ -45,9 +45,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:click
on:mousedown|preventDefault
>
<span class:flip-x={flipX} style={`--width-multiplier: ${widthMultiplier};`}>
<IconConstrain {flipX} {widthMultiplier} {iconSize}>
<slot />
</span>
</IconConstrain>
</button>
<style lang="scss">
@ -61,35 +61,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
@include button.btn-day;
@include button.btn-night;
span {
display: inline-block;
position: relative;
vertical-align: middle;
/* constrain icon */
width: calc((var(--buttons-size) - 2px) * var(--width-multiplier));
height: calc(var(--buttons-size) - 2px);
& > :global(svg),
& > :global(img) {
position: absolute;
width: var(--icon-size);
height: var(--icon-size);
top: calc((100% - var(--icon-size)) / 2);
bottom: calc((100% - var(--icon-size)) / 2);
left: calc((100% - var(--icon-size)) / 2);
right: calc((100% - var(--icon-size)) / 2);
fill: currentColor;
vertical-align: unset;
}
&.flip-x > :global(svg),
&.flip-x > :global(img) {
transform: scaleX(-1);
}
}
.dropdown-toggle::after {
margin-right: 0.25rem;
}

View File

@ -0,0 +1,47 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
export let iconSize: number = 100;
export let widthMultiplier: number = 1;
export let flipX: boolean = false;
</script>
<span
class:flip-x={flipX}
style="--width-multiplier: {widthMultiplier}; --icon-size: {iconSize}%;"
>
<slot />
</span>
<style lang="scss">
span {
display: inline-block;
position: relative;
vertical-align: var(--icon-align, middle);
/* constrain icon */
width: calc((var(--buttons-size, 22px) - 2px) * var(--width-multiplier));
height: calc(var(--buttons-size, 22px) - 2px);
& > :global(svg),
& > :global(img) {
position: absolute;
width: var(--icon-size);
height: var(--icon-size);
top: calc((100% - var(--icon-size)) / 2);
bottom: calc((100% - var(--icon-size)) / 2);
left: calc((100% - var(--icon-size)) / 2);
right: calc((100% - var(--icon-size)) / 2);
fill: currentColor;
vertical-align: unset;
}
&.flip-x > :global(svg),
&.flip-x > :global(img) {
transform: scaleX(-1);
}
}
</style>

View File

@ -28,14 +28,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script>
<!-- div is necessary to preserve item position -->
<div {id}>
<div class="item" {id}>
<Detachable {detached}>
<slot />
</Detachable>
</div>
<style lang="scss">
div {
display: contents;
}
/* TODO reactivate this once we can use CSS gap */
/* .item { */
/* display: contents; */
/* } */
</style>

View File

@ -0,0 +1,21 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import { createEventDispatcher, onMount } from "svelte";
import { registerShortcut } from "../lib/shortcuts";
export let keyCombination: string;
export let target: EventTarget | Document = document;
const dispatch = createEventDispatcher();
onMount(() =>
registerShortcut(
(event: KeyboardEvent) => dispatch("action", { originalEvent: event }),
keyCombination,
target as any
)
);
</script>

View File

@ -17,12 +17,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss">
footer {
position: sticky;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
background: var(--window-bg);
border-top: 1px solid var(--medium-border);
border-style: solid;
border-color: var(--medium-border);
border-width: 0;
padding: 0 3px;
bottom: 0;
border-top-width: 1px;
}
</style>

View File

@ -15,12 +15,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss">
header {
position: sticky;
top: 0;
left: 0;
right: 0;
z-index: 10;
background: var(--window-bg);
border-bottom: 1px solid var(--medium-border);
border-style: solid;
border-color: var(--medium-border);
border-width: 0;
padding: 0 3px;
top: 0;
border-bottom-width: 1px;
}
</style>

View File

@ -1,20 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
export let id: string | undefined = undefined;
let className = "";
export { className as class };
export let style: string;
</script>
<div {id} class={className} {style}>
<slot />
</div>
<style lang="scss">
div {
display: contents;
}
</style>

View File

@ -17,18 +17,22 @@ compile_sass(
],
)
compile_svelte()
_ts_deps = [
"//ts/components",
"//ts/lib",
"//ts/sveltelib",
"@npm//mathjax",
"@npm//mathjax-full",
"@npm//svelte",
]
compile_svelte(deps = _ts_deps)
typescript(
name = "editable",
deps = [
deps = _ts_deps + [
":svelte",
"//ts/components",
"//ts/lib",
"//ts/sveltelib",
"@npm//mathjax",
"@npm//mathjax-full",
"@npm//svelte",
],
)

View File

@ -0,0 +1,40 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { Writable } from "svelte/store";
import { updateAllState } from "../components/WithState.svelte";
export let nodes: Writable<DocumentFragment>;
export let resolve: (editable: HTMLElement) => void;
export let mirror: (
editable: HTMLElement,
params: { store: Writable<DocumentFragment> }
) => void;
</script>
<anki-editable
contenteditable="true"
use:resolve
use:mirror={{ store: nodes }}
on:focus
on:blur
on:click={updateAllState}
on:keyup={updateAllState}
/>
<style lang="scss">
anki-editable {
display: block;
overflow-wrap: break-word;
overflow: auto;
padding: 6px;
&:focus {
outline: none;
}
}
/* editable-base.scss contains styling targeting user HTML */
</style>

View File

@ -34,8 +34,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
onMount(() => {
observer.observe(image);
if (autofocus) {
image.click();
// This should trigger a focusing of the Mathjax Handle
const focusEvent = new CustomEvent("focusmathjax", {
detail: image,
bubbles: true,
composed: true,
});
image.dispatchEvent(focusEvent);
}
});
@ -58,6 +66,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/>
<style lang="scss">
:global(anki-mathjax) {
white-space: pre;
}
img {
vertical-align: var(--vertical-center);
}

View File

@ -20,9 +20,14 @@ export interface DecoratedElement extends HTMLElement {
undecorate(): void;
}
export interface DecoratedElementConstructor extends CustomElementConstructor {
prototype: DecoratedElement;
interface WithTagName {
tagName: string;
}
export interface DecoratedElementConstructor
extends CustomElementConstructor,
WithTagName {
prototype: DecoratedElement;
/**
* Transforms elements in input HTML from undecorated to stored state.
*/
@ -33,13 +38,13 @@ export interface DecoratedElementConstructor extends CustomElementConstructor {
toUndecorated(stored: string): string;
}
class DefineArray extends Array {
push(...elements: DecoratedElementConstructor[]) {
export class CustomElementArray<
T extends CustomElementConstructor & WithTagName
> extends Array<T> {
push(...elements: T[]): number {
for (const element of elements) {
customElements.define(element.tagName, element);
}
return super.push(...elements);
}
}
export const decoratedComponents: DecoratedElementConstructor[] = new DefineArray();

View File

@ -1,22 +1,11 @@
@use "sass/scrollbar";
anki-editable {
display: block;
overflow-wrap: break-word;
overflow: auto;
padding: 6px;
* {
max-width: 100%;
&:focus {
outline: none;
:host(.nightMode) & {
@include scrollbar.night-mode;
}
* {
max-width: 100%;
}
}
anki-mathjax {
white-space: pre;
}
p {
@ -29,27 +18,6 @@ p {
}
}
:host(.nightMode) * {
@include scrollbar.night-mode;
}
img.drawing {
zoom: 50%;
.nightMode & {
filter: unquote("invert() hue-rotate(180deg)");
}
}
[hidden] {
display: none;
}
@import "codemirror/lib/codemirror";
@import "codemirror/theme/monokai";
@import "codemirror/addon/fold/foldgutter";
.CodeMirror {
height: auto;
padding: 6px 0;
}

View File

@ -1,96 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
function loadStyleLink(container: Node, href: string): Promise<void> {
const rootStyle = document.createElement("link");
rootStyle.setAttribute("rel", "stylesheet");
rootStyle.setAttribute("href", href);
let styleResolve: () => void;
const stylePromise = new Promise<void>((resolve) => (styleResolve = resolve));
rootStyle.addEventListener("load", () => styleResolve());
container.appendChild(rootStyle);
return stylePromise;
}
function loadStyleTag(container: Node): [HTMLStyleElement, Promise<void>] {
const style = document.createElement("style");
style.setAttribute("rel", "stylesheet");
let styleResolve: () => void;
const stylePromise = new Promise<void>((resolve) => (styleResolve = resolve));
style.addEventListener("load", () => styleResolve());
container.appendChild(style);
return [style, stylePromise];
}
export class EditableContainer extends HTMLDivElement {
baseStyle: HTMLStyleElement;
imageStyle: HTMLStyleElement;
imagePromise: Promise<void>;
stylePromise: Promise<void>;
baseRule?: CSSStyleRule;
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
if (document.documentElement.classList.contains("night-mode")) {
this.classList.add("night-mode");
}
const rootPromise = loadStyleLink(shadow, "./_anki/css/editable-build.css");
const [baseStyle, basePromise] = loadStyleTag(shadow);
const [imageStyle, imagePromise] = loadStyleTag(shadow);
this.baseStyle = baseStyle;
this.imageStyle = imageStyle;
this.imagePromise = imagePromise;
this.stylePromise = Promise.all([
rootPromise,
basePromise,
imagePromise,
]) as unknown as Promise<void>;
}
connectedCallback(): void {
const sheet = this.baseStyle.sheet as CSSStyleSheet;
const baseIndex = sheet.insertRule("anki-editable {}");
this.baseRule = sheet.cssRules[baseIndex] as CSSStyleRule;
}
initialize(color: string): void {
this.setBaseColor(color);
}
setBaseColor(color: string): void {
if (this.baseRule) {
this.baseRule.style.color = color;
}
}
setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
if (this.baseRule) {
this.baseRule.style.fontFamily = fontFamily;
this.baseRule.style.fontSize = fontSize;
this.baseRule.style.direction = direction;
}
}
isRightToLeft(): boolean {
return this.baseRule!.style.direction === "rtl";
}
}
customElements.define("anki-editable-container", EditableContainer, { extends: "div" });

View File

@ -1,89 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/// <reference types="../lib/shadow-dom" />
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
import type { DecoratedElement } from "./decorated";
import { decoratedComponents } from "./decorated";
import { bridgeCommand } from "../lib/bridgecommand";
import { elementIsBlock, getBlockElement } from "../lib/dom";
import { wrapInternal } from "../lib/wrap";
export function caretToEnd(node: Node): void {
const range = document.createRange();
range.selectNodeContents(node);
range.collapse(false);
const selection = (node.getRootNode() as Document | ShadowRoot).getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
}
function containsInlineContent(element: Element): boolean {
for (const child of element.children) {
if (elementIsBlock(child) || !containsInlineContent(child)) {
return false;
}
}
return true;
}
export class Editable extends HTMLElement {
set fieldHTML(content: string) {
this.innerHTML = content;
if (content.length > 0 && containsInlineContent(this)) {
this.appendChild(document.createElement("br"));
}
}
get fieldHTML(): string {
const clone = this.cloneNode(true) as Element;
for (const component of decoratedComponents) {
for (const element of clone.getElementsByTagName(component.tagName)) {
(element as DecoratedElement).undecorate();
}
}
const result =
containsInlineContent(clone) && clone.innerHTML.endsWith("<br>")
? clone.innerHTML.slice(0, -4) // trim trailing <br>
: clone.innerHTML;
return result;
}
connectedCallback(): void {
this.setAttribute("contenteditable", "");
}
caretToEnd(): void {
caretToEnd(this);
}
surroundSelection(before: string, after: string): void {
wrapInternal(this.getRootNode() as ShadowRoot, before, after, false);
}
onEnter(event: KeyboardEvent): void {
if (
!getBlockElement(this.getRootNode() as Document | ShadowRoot) !==
event.shiftKey
) {
event.preventDefault();
document.execCommand("insertLineBreak");
}
}
onPaste(event: ClipboardEvent): void {
bridgeCommand("paste");
event.preventDefault();
}
}
customElements.define("anki-editable", Editable);

View File

@ -2,6 +2,7 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import "./editable-base.css";
import "./editable-container";
import "./editable";
import "./mathjax-component";
/* only imported for the CSS */
import "./ContentEditable.svelte";
import "./Mathjax.svelte";

View File

@ -9,7 +9,6 @@
import "mathjax/es5/tex-svg-full";
import type { DecoratedElement, DecoratedElementConstructor } from "./decorated";
import { decoratedComponents } from "./decorated";
import { nodeIsElement } from "../lib/dom";
import { nightModeKey } from "../components/context-keys";
@ -38,7 +37,7 @@ function moveNodeOutOfElement(
}
function placeCaretAfter(node: Node): void {
const range = document.createRange();
const range = new Range();
range.setStartAfter(node);
range.collapse(false);
@ -192,5 +191,3 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
}
}
};
decoratedComponents.push(Mathjax);

View File

@ -8,7 +8,7 @@ load("//ts:typescript.bzl", "typescript")
compile_sass(
srcs = [
"fields.scss",
"editor-base.scss",
],
group = "base_css",
visibility = ["//visibility:public"],
@ -33,21 +33,24 @@ compile_sass(
],
)
compile_svelte()
_ts_deps = [
"//ts/components",
"//ts/editable",
"//ts/html-filter",
"//ts/lib",
"//ts/sveltelib",
"@npm//@fluent",
"@npm//@types/codemirror",
"@npm//codemirror",
"@npm//svelte",
]
compile_svelte(deps = _ts_deps)
typescript(
name = "editor_ts",
deps = [
deps = _ts_deps + [
":svelte",
"//ts/components",
"//ts/editable",
"//ts/html-filter",
"//ts/lib",
"//ts/sveltelib",
"@npm//@fluent",
"@npm//@types/codemirror",
"@npm//codemirror",
"@npm//svelte",
],
)
@ -79,13 +82,14 @@ eslint_test()
svelte_check(
name = "svelte_check",
srcs = glob([
"*.ts",
"*.svelte",
"**/*.ts",
"**/*.svelte",
]) + [
"//sass:button_mixins_lib",
"//sass/bootstrap",
"@npm//@types/bootstrap",
"//ts/components",
"//ts/editable",
"@npm//@types/bootstrap",
"@npm//@types/codemirror",
"@npm//codemirror",
],

View File

@ -5,20 +5,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="typescript">
import IconButton from "../components/IconButton.svelte";
import WithShortcut from "../components/WithShortcut.svelte";
import OnlyEditable from "./OnlyEditable.svelte";
import * as tr from "../lib/ftl";
import { withButton } from "../components/helpers";
import { ellipseIcon } from "./icons";
import { forEditorField } from ".";
import { wrapCurrent } from "./wrap";
import { get } from "svelte/store";
import { getNoteEditor } from "./OldEditorAdapter.svelte";
const noteEditor = getNoteEditor();
const { focusInRichText, activeInput } = noteEditor;
const clozePattern = /\{\{c(\d+)::/gu;
function getCurrentHighestCloze(increment: boolean): number {
let highest = 0;
forEditorField([], (field) => {
const fieldHTML = field.editingArea.fieldHTML;
for (const field of noteEditor.fields) {
const content = field.editingArea?.content;
const fieldHTML = content ? get(content) : "";
const matches: number[] = [];
let match: RegExpMatchArray | null = null;
@ -27,7 +31,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
highest = Math.max(highest, ...matches);
});
}
if (increment) {
highest++;
@ -38,19 +42,19 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function onCloze(event: KeyboardEvent | MouseEvent): void {
const highestCloze = getCurrentHighestCloze(!event.getModifierState("Alt"));
wrapCurrent(`{{c${highestCloze}::`, "}}");
$activeInput?.surround(`{{c${highestCloze}::`, "}}");
}
$: disabled = !$focusInRichText;
</script>
<WithShortcut shortcut={"Control+Alt?+Shift+C"} let:createShortcut let:shortcutLabel>
<OnlyEditable let:disabled>
<IconButton
tooltip={`${tr.editingClozeDeletion()} (${shortcutLabel})`}
{disabled}
on:click={onCloze}
on:mount={withButton(createShortcut)}
>
{@html ellipseIcon}
</IconButton>
</OnlyEditable>
<WithShortcut shortcut="Control+Alt?+Shift+C" let:createShortcut let:shortcutLabel>
<IconButton
tooltip="{tr.editingClozeDeletion()} {shortcutLabel}"
{disabled}
on:click={onCloze}
on:mount={withButton(createShortcut)}
>
{@html ellipseIcon}
</IconButton>
</WithShortcut>

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 context="module" lang="typescript">
import { CodeMirror as CodeMirrorLib } from "./code-mirror";
export interface CodeMirrorAPI {
readonly editor: CodeMirrorLib.EditorFromTextArea;
}
</script>
<script lang="typescript">
import { createEventDispatcher } from "svelte";
import type { Writable } from "svelte/store";
import storeSubscribe from "../sveltelib/store-subscribe";
export let configuration: CodeMirror.EditorConfiguration;
export let code: Writable<string>;
let codeMirror: CodeMirror.EditorFromTextArea;
function setValue(content: string): void {
codeMirror.setValue(content);
}
const { subscribe, unsubscribe } = storeSubscribe(code, setValue, false);
const dispatch = createEventDispatcher();
function openCodeMirror(textarea: HTMLTextAreaElement): void {
codeMirror = CodeMirrorLib.fromTextArea(textarea, configuration);
// TODO passing in the tabindex option does not do anything: bug?
codeMirror.getInputField().tabIndex = 0;
codeMirror.on("change", () => dispatch("change", codeMirror.getValue()));
codeMirror.on("focus", () => {
unsubscribe();
dispatch("focus");
});
codeMirror.on("blur", () => {
subscribe();
dispatch("blur");
});
subscribe();
}
export const api = Object.create(
{},
{
editor: { get: () => codeMirror },
}
) as CodeMirrorAPI;
</script>
<div class="code-mirror">
<textarea tabindex="-1" hidden use:openCodeMirror />
</div>

View File

@ -9,13 +9,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ColorPicker from "../components/ColorPicker.svelte";
import WithShortcut from "../components/WithShortcut.svelte";
import WithColorHelper from "./WithColorHelper.svelte";
import OnlyEditable from "./OnlyEditable.svelte";
import * as tr from "../lib/ftl";
import { bridgeCommand } from "../lib/bridgecommand";
import { withButton } from "../components/helpers";
import { textColorIcon, highlightColorIcon, arrowIcon } from "./icons";
import { appendInParentheses, execCommand } from "./helpers";
import { getNoteEditor } from "./OldEditorAdapter.svelte";
export let api = {};
export let textColor: string;
@ -31,84 +31,83 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const wrapWithBackcolor = (color: string) => () => {
execCommand("backcolor", false, color);
};
const { focusInRichText } = getNoteEditor();
$: disabled = !$focusInRichText;
</script>
<ButtonGroup {api}>
<WithColorHelper color={textColor} let:colorHelperIcon let:setColor>
<OnlyEditable let:disabled>
<ButtonGroupItem>
<WithShortcut shortcut={"F7"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(
tr.editingSetTextColor(),
shortcutLabel
)}
{disabled}
on:click={forecolorWrap}
on:mount={withButton(createShortcut)}
>
{@html textColorIcon}
{@html colorHelperIcon}
</IconButton>
</WithShortcut>
</ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"F8"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(
tr.editingChangeColor(),
shortcutLabel
)}
{disabled}
widthMultiplier={0.5}
>
{@html arrowIcon}
<ColorPicker
on:change={(event) => {
const textColor = setColor(event);
bridgeCommand(`lastTextColor:${textColor}`);
forecolorWrap = wrapWithForecolor(setColor(event));
forecolorWrap();
}}
on:mount={withButton(createShortcut)}
/>
</IconButton>
</WithShortcut>
</ButtonGroupItem>
</OnlyEditable>
</WithColorHelper>
<WithColorHelper color={highlightColor} let:colorHelperIcon let:setColor>
<OnlyEditable let:disabled>
<ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"F7"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={tr.editingSetTextHighlightColor()}
tooltip={appendInParentheses(
tr.editingSetTextColor(),
shortcutLabel
)}
{disabled}
on:click={backcolorWrap}
on:click={forecolorWrap}
on:mount={withButton(createShortcut)}
>
{@html highlightColorIcon}
{@html textColorIcon}
{@html colorHelperIcon}
</IconButton>
</ButtonGroupItem>
</WithShortcut>
</ButtonGroupItem>
<ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"F8"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={tr.editingChangeColor()}
widthMultiplier={0.5}
tooltip={appendInParentheses(
tr.editingChangeColor(),
shortcutLabel
)}
{disabled}
widthMultiplier={0.5}
>
{@html arrowIcon}
<ColorPicker
on:change={(event) => {
const highlightColor = setColor(event);
bridgeCommand(`lastHighlightColor:${highlightColor}`);
backcolorWrap = wrapWithBackcolor(highlightColor);
backcolorWrap();
const textColor = setColor(event);
bridgeCommand(`lastTextColor:${textColor}`);
forecolorWrap = wrapWithForecolor(setColor(event));
forecolorWrap();
}}
on:mount={withButton(createShortcut)}
/>
</IconButton>
</ButtonGroupItem>
</OnlyEditable>
</WithShortcut>
</ButtonGroupItem>
</WithColorHelper>
<WithColorHelper color={highlightColor} let:colorHelperIcon let:setColor>
<ButtonGroupItem>
<IconButton
tooltip={tr.editingSetTextHighlightColor()}
{disabled}
on:click={backcolorWrap}
>
{@html highlightColorIcon}
{@html colorHelperIcon}
</IconButton>
</ButtonGroupItem>
<ButtonGroupItem>
<IconButton
tooltip={tr.editingChangeColor()}
widthMultiplier={0.5}
{disabled}
>
{@html arrowIcon}
<ColorPicker
on:change={(event) => {
const highlightColor = setColor(event);
bridgeCommand(`lastHighlightColor:${highlightColor}`);
backcolorWrap = wrapWithBackcolor(highlightColor);
backcolorWrap();
}}
/>
</IconButton>
</ButtonGroupItem>
</WithColorHelper>
</ButtonGroup>

View File

@ -6,10 +6,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import IconButton from "../components/IconButton.svelte";
import WithShortcut from "../components/WithShortcut.svelte";
import WithState from "../components/WithState.svelte";
import OnlyEditable from "./OnlyEditable.svelte";
import { withButton } from "../components/helpers";
import { appendInParentheses, execCommand, queryCommandState } from "./helpers";
import { getNoteEditor } from "./OldEditorAdapter.svelte";
export let key: string;
export let tooltip: string;
@ -17,14 +17,47 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let withoutShortcut = false;
export let withoutState = false;
const { focusInRichText } = getNoteEditor();
$: disabled = !$focusInRichText;
</script>
<OnlyEditable let:disabled>
{#if withoutShortcut && withoutState}
<IconButton {tooltip} {disabled} on:click={() => execCommand(key)}>
{#if withoutShortcut && withoutState}
<IconButton {tooltip} {disabled} on:click={() => execCommand(key)}>
<slot />
</IconButton>
{:else if withoutShortcut}
<WithState
{key}
update={() => queryCommandState(key)}
let:state={active}
let:updateState
>
<IconButton
{tooltip}
{active}
{disabled}
on:click={(event) => {
execCommand(key);
updateState(event);
}}
>
<slot />
</IconButton>
{:else if withoutShortcut}
</WithState>
{:else if withoutState}
<WithShortcut {shortcut} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(tooltip, shortcutLabel)}
{disabled}
on:click={() => execCommand(key)}
on:mount={withButton(createShortcut)}
>
<slot />
</IconButton>
</WithShortcut>
{:else}
<WithShortcut {shortcut} let:createShortcut let:shortcutLabel>
<WithState
{key}
update={() => queryCommandState(key)}
@ -32,49 +65,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let:updateState
>
<IconButton
{tooltip}
tooltip={appendInParentheses(tooltip, shortcutLabel)}
{active}
{disabled}
on:click={(event) => {
execCommand(key);
updateState(event);
}}
>
<slot />
</IconButton>
</WithState>
{:else if withoutState}
<WithShortcut {shortcut} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(tooltip, shortcutLabel)}
{disabled}
on:click={() => execCommand(key)}
on:mount={withButton(createShortcut)}
>
<slot />
</IconButton>
</WithShortcut>
{:else}
<WithShortcut {shortcut} let:createShortcut let:shortcutLabel>
<WithState
{key}
update={() => queryCommandState(key)}
let:state={active}
let:updateState
>
<IconButton
tooltip={appendInParentheses(tooltip, shortcutLabel)}
{active}
{disabled}
on:click={(event) => {
execCommand(key);
updateState(event);
}}
on:mount={withButton(createShortcut)}
>
<slot />
</IconButton>
</WithState>
</WithShortcut>
{/if}
</OnlyEditable>
</WithState>
</WithShortcut>
{/if}

View File

@ -10,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import WithState from "../components/WithState.svelte";
import * as contextKeys from "../components/context-keys";
import * as editorContextKeys from "./context-keys";
import * as editorContextKeys from "./NoteEditor.svelte";
export const components = {
IconButton,

View File

@ -0,0 +1,85 @@
<!--
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">
interface Identifiable {
id: string;
}
export interface StyleLinkType extends Identifiable {
type: "link";
href: string;
}
export interface StyleTagType extends Identifiable {
type: "style";
}
export type StyleType = StyleLinkType | StyleTagType;
type StyleHTMLTag = HTMLStyleElement | HTMLLinkElement;
export interface StyleObject {
element: StyleHTMLTag;
}
export const customStylesKey = Symbol("customStyles");
</script>
<script lang="ts">
import { setContext } from "svelte";
import StyleLink from "./StyleLink.svelte";
import StyleTag from "./StyleTag.svelte";
export let styles: StyleType[];
export const styleMap = new Map<string, StyleObject>();
const resolvers = new Map<string, (object: StyleObject) => void>();
function register(id: string, object: StyleObject): void {
styleMap.set(id, object);
if (resolvers.has(id)) {
resolvers.get(id)!(object);
resolvers.delete(id);
}
}
function deregister(id: string): void {
styleMap.delete(id);
}
setContext(customStylesKey, { register, deregister });
function waitForRegistration(id: string): Promise<StyleObject> {
let styleResolve: (element: StyleObject) => void;
const promise = new Promise<StyleObject>((resolve) => (styleResolve = resolve));
resolvers.set(id, styleResolve!);
return promise;
}
export function addStyleLink(id: string, href: string): Promise<StyleObject> {
styles.push({ id, type: "link", href });
styles = styles;
return waitForRegistration(id);
}
export function addStyleTag(id: string): Promise<StyleObject> {
styles.push({ id, type: "style" });
styles = styles;
return waitForRegistration(id);
}
</script>
{#each styles as style (style.id)}
{#if style.type === "link"}
<StyleLink id={style.id} href={style.href} />
{:else}
<StyleTag id={style.id} />
{/if}
{/each}

View File

@ -0,0 +1,27 @@
<!--
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 {
CustomElementArray,
DecoratedElementConstructor,
} from "../editable/decorated";
import { Mathjax } from "../editable/mathjax-element";
import contextProperty from "../sveltelib/context-property";
const decoratedElements = new CustomElementArray<DecoratedElementConstructor>();
decoratedElements.push(Mathjax);
const key = Symbol("decoratedElements");
const [set, getDecoratedElements, hasDecoratedElements] =
contextProperty<CustomElementArray<DecoratedElementConstructor>>(key);
export { getDecoratedElements, hasDecoratedElements };
</script>
<script lang="ts">
set(decoratedElements);
</script>
<slot />

View File

@ -0,0 +1,164 @@
<!--
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 EditingInputAPI {
readonly name: string;
focusable: boolean;
focus(): void;
refocus(): void;
}
export interface EditingAreaAPI {
content: Writable<string>;
editingInputs: Writable<EditingInputAPI[]>;
focus(): void;
refocus(): void;
}
const key = Symbol("editingArea");
const [set, getEditingArea, hasEditingArea] = contextProperty<EditingAreaAPI>(key);
export { getEditingArea, hasEditingArea };
</script>
<script lang="ts">
import { writable } from "svelte/store";
import { onMount, setContext as svelteSetContext } from "svelte";
import { fontFamilyKey, fontSizeKey } from "../lib/context-keys";
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>;
export let autofocus = false;
let editingArea: HTMLElement;
let focusTrap: HTMLInputElement;
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 (document.activeElement === focusTrap) {
focusEditingInputIfAvailable();
}
}
$: {
$inputsStore;
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
function trapFocusOnBlurOut(event: FocusEvent): void {
if (!event.relatedTarget && editingInputs.every((input) => !input.focusable)) {
focusTrap.focus();
event.preventDefault();
}
}
export const api = set({
content,
editingInputs: inputsStore,
focus,
refocus,
});
onMount(() => {
if (autofocus) {
focus();
}
});
</script>
<input
bind:this={focusTrap}
readonly
tabindex="-1"
class="focus-trap"
on:focus={focusEditingInputInsteadIfAvailable}
/>
<div bind:this={editingArea} class="editing-area" on:focusout={trapFocusOnBlurOut}>
<slot />
</div>
<style lang="scss">
.editing-area {
position: relative;
background: var(--frame-bg);
border-radius: 0 0 5px 5px;
&:focus {
outline: none;
}
}
.focus-trap {
display: block;
width: 0px;
height: 0;
padding: 0;
margin: 0;
border: none;
outline: none;
-webkit-appearance: none;
background: none;
resize: none;
appearance: none;
}
</style>

View File

@ -0,0 +1,104 @@
<!--
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 { EditingAreaAPI } from "./EditingArea.svelte";
import contextProperty from "../sveltelib/context-property";
export interface FieldData {
name: string;
fontFamily: string;
fontSize: number;
direction: "ltr" | "rtl";
}
export interface EditorFieldAPI {
element: HTMLElement;
index: number;
direction: "ltr" | "rtl";
editingArea?: EditingAreaAPI;
}
const key = Symbol("editorField");
const [set, getEditorField, hasEditorField] = contextProperty<EditorFieldAPI>(key);
export { getEditorField, hasEditorField };
</script>
<script lang="ts">
import EditingArea from "./EditingArea.svelte";
import LabelContainer from "./LabelContainer.svelte";
import LabelName from "./LabelName.svelte";
import FieldState from "./FieldState.svelte";
import { setContext } from "svelte";
import { writable } from "svelte/store";
import type { Writable } from "svelte/store";
import { directionKey } from "../lib/context-keys";
export let content: Writable<string>;
export let field: FieldData;
export let autofocus = false;
const directionStore = writable();
setContext(directionKey, directionStore);
$: $directionStore = field.direction;
let editorField: HTMLElement;
export const api = set(
Object.create(
{},
{
element: {
get: () => editorField,
},
direction: {
get: () => $directionStore,
},
}
) as EditorFieldAPI
);
</script>
<div
bind:this={editorField}
class="editor-field"
on:focusin
on:focusout
on:click={() => api.editingArea?.focus()}
>
<LabelContainer>
<LabelName>{field.name}</LabelName>
<FieldState><slot name="field-state" /></FieldState>
</LabelContainer>
<EditingArea
{content}
{autofocus}
fontFamily={field.fontFamily}
fontSize={field.fontSize}
bind:api={api.editingArea}
>
<slot name="editing-inputs" />
</EditingArea>
</div>
<style lang="scss">
.editor-field {
--border-color: var(--border);
border-radius: 5px;
border: 1px solid var(--border-color);
min-width: 0;
&:focus-within {
--border-color: var(--focus-border);
outline: none;
box-shadow: 0 0 0 3px var(--focus-shadow);
}
}
</style>

View File

@ -25,7 +25,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script>
<script lang="typescript">
import { isApplePlatform } from "../lib/platform";
import StickyHeader from "../components/StickyHeader.svelte";
import ButtonToolbar from "../components/ButtonToolbar.svelte";
import Item from "../components/Item.svelte";
@ -36,8 +35,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ColorButtons from "./ColorButtons.svelte";
import TemplateButtons from "./TemplateButtons.svelte";
export let size = isApplePlatform() ? 1.6 : 2.0;
export let wrap = true;
export let size: number;
export let wrap: boolean;
export let textColor: string;
export let highlightColor: string;

View File

@ -0,0 +1,17 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<span class="field-state justify-content-between">
<slot />
</span>
<style lang="scss">
.field-state {
display: flex;
& > :global(*) {
margin-left: 2px;
}
}
</style>

27
ts/editor/Fields.svelte Normal file
View File

@ -0,0 +1,27 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<div class="fields">
<slot />
</div>
<style lang="scss">
.fields {
display: grid;
grid-auto-rows: min-content;
grid-gap: 4px;
padding: 5px 3px 0;
/* set height to 100% for rich text widgets */
height: 100%;
/* moves the scrollbar inside the editor */
overflow-x: hidden;
> :global(:last-child) {
/* bottom padding is eaten by overflow-x */
margin-bottom: 5px;
}
}
</style>

View File

@ -0,0 +1,17 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<div class="multi-root-editor">
<slot />
</div>
<style lang="scss">
.multi-root-editor {
position: relative;
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-x: hidden;
}
</style>

View File

@ -9,12 +9,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ButtonDropdown from "../components/ButtonDropdown.svelte";
import Item from "../components/Item.svelte";
import WithDropdown from "../components/WithDropdown.svelte";
import OnlyEditable from "./OnlyEditable.svelte";
import CommandIconButton from "./CommandIconButton.svelte";
import * as tr from "../lib/ftl";
import { getListItem } from "../lib/dom";
import { getCurrentField, execCommand } from "./helpers";
import { execCommand } from "./helpers";
import {
ulIcon,
olIcon,
@ -26,12 +25,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
indentIcon,
outdentIcon,
} from "./icons";
import { getNoteEditor } from "./OldEditorAdapter.svelte";
export let api = {};
function outdentListItem() {
const currentField = getCurrentField();
if (getListItem(currentField!.editableContainer.shadowRoot!)) {
if (getListItem(document.activeElement!.shadowRoot!)) {
execCommand("outdent");
} else {
alert("Indent/unindent currently only works with lists.");
@ -39,13 +38,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
function indentListItem() {
const currentField = getCurrentField();
if (getListItem(currentField!.editableContainer.shadowRoot!)) {
if (getListItem(document.activeElement!.shadowRoot!)) {
execCommand("indent");
} else {
alert("Indent/unindent currently only works with lists.");
}
}
const { focusInRichText } = getNoteEditor();
$: disabled = !$focusInRichText;
</script>
<ButtonGroup {api}>
@ -67,14 +68,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroupItem>
<WithDropdown let:createDropdown>
<OnlyEditable let:disabled>
<IconButton
{disabled}
on:mount={(event) => createDropdown(event.detail.button)}
>
{@html listOptionsIcon}
</IconButton>
</OnlyEditable>
<IconButton
{disabled}
on:mount={(event) => createDropdown(event.detail.button)}
>
{@html listOptionsIcon}
</IconButton>
<ButtonDropdown>
<Item id="justify">
@ -120,27 +119,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<Item id="indentation">
<ButtonGroup>
<ButtonGroupItem>
<OnlyEditable let:disabled>
<IconButton
on:click={outdentListItem}
tooltip={tr.editingOutdent()}
{disabled}
>
{@html outdentIcon}
</IconButton>
</OnlyEditable>
<IconButton
on:click={outdentListItem}
tooltip={tr.editingOutdent()}
{disabled}
>
{@html outdentIcon}
</IconButton>
</ButtonGroupItem>
<ButtonGroupItem>
<OnlyEditable let:disabled>
<IconButton
on:click={indentListItem}
tooltip={tr.editingIndent()}
{disabled}
>
{@html indentIcon}
</IconButton>
</OnlyEditable>
<IconButton
on:click={indentListItem}
tooltip={tr.editingIndent()}
{disabled}
>
{@html indentIcon}
</IconButton>
</ButtonGroupItem>
</ButtonGroup>
</Item>

View File

@ -3,18 +3,22 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { Readable } from "svelte/store";
import { getContext } from "svelte";
import { directionKey } from "../lib/context-keys";
import { afterUpdate, createEventDispatcher, onMount } from "svelte";
export let isRtl: boolean;
let dimensions: HTMLDivElement;
let overflowFix = 0;
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey);
function updateOverflow(dimensions: HTMLDivElement): void {
const boundingClientRect = dimensions.getBoundingClientRect();
const overflow = isRtl
? window.innerWidth - boundingClientRect.x - boundingClientRect.width
: boundingClientRect.x;
const overflow =
$direction === "ltr"
? boundingClientRect.x
: window.innerWidth - boundingClientRect.x - boundingClientRect.width;
overflowFix = Math.min(0, overflowFix + overflow, overflow);
}
@ -33,7 +37,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div
bind:this={dimensions}
class="image-handle-dimensions"
class:is-rtl={isRtl}
class:is-rtl={$direction === "rtl"}
style="--overflow-fix: {overflowFix}px"
use:updateOverflowAsync
>

View File

@ -1,26 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import * as tr from "../lib/ftl";
import ButtonGroup from "../components/ButtonGroup.svelte";
import ButtonGroupItem from "../components/ButtonGroupItem.svelte";
import IconButton from "../components/IconButton.svelte";
import { sizeActual, sizeMinimized } from "./icons";
export let active: boolean;
export let isRtl: boolean;
$: icon = active ? sizeActual : sizeMinimized;
</script>
<ButtonGroup size={1.6}>
<ButtonGroupItem>
<IconButton {active} flipX={isRtl} tooltip={tr.editingActualSize()} on:click
>{@html icon}</IconButton
>
</ButtonGroupItem>
</ButtonGroup>

View File

@ -0,0 +1,39 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { Readable } from "svelte/store";
import { getContext } from "svelte";
import { directionKey } from "../lib/context-keys";
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey);
</script>
<div
class="label-container"
class:rtl={$direction === "rtl"}
on:mousedown|preventDefault
>
<slot />
</div>
<style lang="scss">
.label-container {
display: flex;
justify-content: space-between;
background-color: var(--label-color, transparent);
border-width: 0 0 1px;
border-style: dashed;
border-color: var(--border-color);
border-radius: 5px 5px 0 0;
padding: 0px 6px;
}
.rtl {
direction: rtl;
}
</style>

View File

@ -0,0 +1,11 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<span class="label-name"><slot /></span>
<style lang="scss">
.label-name {
user-select: none;
}
</style>

View File

@ -1,109 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import WithDropdown from "../components/WithDropdown.svelte";
import ButtonToolbar from "../components/ButtonToolbar.svelte";
import DropdownMenu from "../components/DropdownMenu.svelte";
import Item from "../components/Item.svelte";
import HandleSelection from "./HandleSelection.svelte";
import HandleBackground from "./HandleBackground.svelte";
import HandleControl from "./HandleControl.svelte";
import MathjaxHandleInlineBlock from "./MathjaxHandleInlineBlock.svelte";
import MathjaxHandleEditor from "./MathjaxHandleEditor.svelte";
export let activeImage: HTMLImageElement | null = null;
export let container: HTMLElement;
export let isRtl: boolean;
let dropdownApi: any;
let removeEventListener: () => void = () => {
/* noop */
};
function onImageResize(): void {
if (activeImage) {
errorMessage = activeImage.title;
updateSelection().then(() => dropdownApi.update());
}
}
$: if (activeImage) {
activeImage.addEventListener("resize", onImageResize);
const lastImage = activeImage;
removeEventListener = () =>
lastImage.removeEventListener("resize", onImageResize);
} else {
removeEventListener();
}
const resizeObserver = new ResizeObserver(async () => {
if (activeImage) {
await updateSelection();
dropdownApi.update();
}
});
resizeObserver.observe(container);
let updateSelection: () => Promise<void>;
let errorMessage: string;
function getComponent(image: HTMLImageElement): HTMLElement {
return image.closest("anki-mathjax")! as HTMLElement;
}
function onEditorUpdate(event: CustomEvent): void {
/* this updates the image in Mathjax.svelte */
getComponent(activeImage!).dataset.mathjax = event.detail.mathjax;
}
</script>
<WithDropdown
drop="down"
autoOpen={true}
autoClose={false}
distance={4}
let:createDropdown
>
{#if activeImage}
<HandleSelection
image={activeImage}
{container}
bind:updateSelection
on:mount={(event) => (dropdownApi = createDropdown(event.detail.selection))}
>
<HandleBackground tooltip={errorMessage} />
<HandleControl offsetX={1} offsetY={1} />
</HandleSelection>
<DropdownMenu>
<MathjaxHandleEditor
initialValue={getComponent(activeImage).dataset.mathjax ?? ""}
on:update={onEditorUpdate}
/>
<div class="margin-x">
<ButtonToolbar>
<Item>
<MathjaxHandleInlineBlock
{activeImage}
{isRtl}
on:click={updateSelection}
/>
</Item>
</ButtonToolbar>
</div>
</DropdownMenu>
{/if}
</WithDropdown>
<style lang="scss">
.margin-x {
margin: 0 0.125rem;
}
</style>

View File

@ -0,0 +1,16 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<div class="note-editor">
<slot />
</div>
<style lang="scss">
.note-editor {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@ -0,0 +1,22 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { fly } from "svelte/transition";
</script>
<div class="notification" transition:fly={{ x: 200 }}>
<slot />
</div>
<style lang="scss">
.notification {
background-color: var(--notification-bg, var(--window-bg));
user-select: none;
border: 1px solid var(--medium-border);
border-radius: 5px;
padding: 0.9rem 1.2rem;
}
</style>

View File

@ -0,0 +1,323 @@
<!--
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 { EditorFieldAPI } from "./EditorField.svelte";
import type { RichTextInputAPI } from "./RichTextInput.svelte";
import type { PlainTextInputAPI } from "./PlainTextInput.svelte";
import contextProperty from "../sveltelib/context-property";
export interface NoteEditorAPI {
fields: EditorFieldAPI[];
currentField: Writable<EditorFieldAPI>;
activeInput: Writable<RichTextInputAPI | PlainTextInputAPI | null>;
focusInRichText: Writable<boolean>;
}
const key = Symbol("noteEditor");
const [set, getNoteEditor, hasNoteEditor] = contextProperty<NoteEditorAPI>(key);
export { getNoteEditor, hasNoteEditor };
</script>
<script lang="ts">
import NoteEditor from "./NoteEditor.svelte";
import FieldsEditor from "./FieldsEditor.svelte";
import Fields from "./Fields.svelte";
import EditorField from "./EditorField.svelte";
import type { FieldData } from "./EditorField.svelte";
import TagEditor from "./TagEditor.svelte";
import EditorToolbar from "./EditorToolbar.svelte";
import Notification from "./Notification.svelte";
import Absolute from "../components/Absolute.svelte";
import Badge from "../components/Badge.svelte";
import DecoratedElements from "./DecoratedElements.svelte";
import RichTextInput from "./RichTextInput.svelte";
import { MathjaxHandle } from "./mathjax-overlay";
import { ImageHandle } from "./image-overlay";
import PlainTextInput from "./PlainTextInput.svelte";
import RichTextBadge from "./RichTextBadge.svelte";
import PlainTextBadge from "./PlainTextBadge.svelte";
import StickyBadge from "./StickyBadge.svelte";
import { onMount, onDestroy } from "svelte";
import type { Writable } from "svelte/store";
import { writable, get } from "svelte/store";
import { bridgeCommand } from "../lib/bridgecommand";
import { isApplePlatform } from "../lib/platform";
import { ChangeTimer } from "./change-timer";
import { alertIcon } from "./icons";
function quoteFontFamily(fontFamily: string): string {
// generic families (e.g. sans-serif) must not be quoted
if (!/^[-a-z]+$/.test(fontFamily)) {
fontFamily = `"${fontFamily}"`;
}
return fontFamily;
}
let size = isApplePlatform() ? 1.6 : 2.0;
let wrap = true;
let fieldStores: Writable<string>[] = [];
let fieldNames: string[] = [];
export function setFields(fs: [string, string][]): void {
// this is a bit of a mess -- when moving to Rust calls, we should make
// sure to have two backend endpoints for:
// * the note, which can be set through this view
// * the fieldname, font, etc., which cannot be set
const newFieldNames: string[] = [];
for (const [index, [fieldName]] of fs.entries()) {
newFieldNames[index] = fieldName;
}
for (let i = fieldStores.length; i < newFieldNames.length; i++) {
const newStore = writable("");
fieldStores[i] = newStore;
newStore.subscribe((value) => updateField(i, value));
}
for (let i = fieldStores.length; i > newFieldNames.length; i++) {
fieldStores.pop();
}
for (const [index, [_, fieldContent]] of fs.entries()) {
fieldStores[index].set(fieldContent);
}
fieldNames = newFieldNames;
}
let fonts: [string, number, boolean][] = [];
let richTextsHidden: boolean[] = [];
let plainTextsHidden: boolean[] = [];
export function setFonts(fs: [string, number, boolean][]): void {
fonts = fs;
richTextsHidden = fonts.map((_, index) => richTextsHidden[index] ?? false);
plainTextsHidden = fonts.map((_, index) => plainTextsHidden[index] ?? true);
}
let focusTo: number = 0;
export function focusField(n: number): void {
focusTo = n;
fieldApis[focusTo]?.editingArea?.refocus();
}
let textColor: string = "black";
let highlightColor: string = "black";
export function setColorButtons([textClr, highlightClr]: [string, string]): void {
textColor = textClr;
highlightColor = highlightClr;
}
let tags = writable<string[]>([]);
export function setTags(ts: string[]): void {
$tags = ts;
}
let stickies: boolean[] | null = null;
export function setSticky(sts: boolean[]): void {
stickies = sts;
}
let noteId: number | null = null;
export function setNoteId(ntid: number): void {
noteId = ntid;
}
function getNoteId(): number | null {
return noteId;
}
let cols: ("dupe" | "")[] = [];
export function setBackgrounds(cls: ("dupe" | "")[]): void {
cols = cls;
}
let hint: string = "";
export function setClozeHint(hnt: string): void {
hint = hnt;
}
$: fieldsData = fieldNames.map((name, index) => ({
name,
fontFamily: quoteFontFamily(fonts[index][0]),
fontSize: fonts[index][1],
direction: fonts[index][2] ? "rtl" : "ltr",
})) as FieldData[];
function saveTags({ detail }: CustomEvent): void {
bridgeCommand(`saveTags:${JSON.stringify(detail.tags)}`);
}
const fieldSave = new ChangeTimer();
function updateField(index: number, content: string): void {
fieldSave.schedule(
() => bridgeCommand(`key:${index}:${getNoteId()}:${content}`),
600
);
}
export function saveFieldNow(): void {
/* this will always be a key save */
fieldSave.fireImmediately();
}
export function saveOnPageHide() {
if (document.visibilityState === "hidden") {
// will fire on session close and minimize
saveFieldNow();
}
}
function toggleStickyAll(): void {
bridgeCommand("toggleStickyAll", (values: boolean[]) => (stickies = values));
}
import { registerShortcut } from "../lib/shortcuts";
let deregisterSticky: () => void;
export function activateStickyShortcuts() {
deregisterSticky = registerShortcut(toggleStickyAll, "Shift+F9");
}
export function focusIfField(x: number, y: number): boolean {
const elements = document.elementsFromPoint(x, y);
const first = elements[0];
if (first.shadowRoot) {
const richTextInput = first.shadowRoot.lastElementChild! as HTMLElement;
richTextInput.focus();
return true;
}
return false;
}
let richTextInputs: RichTextInput[] = [];
$: richTextInputs = richTextInputs.filter(Boolean);
let plainTextInputs: PlainTextInput[] = [];
$: plainTextInputs = plainTextInputs.filter(Boolean);
let editorFields: EditorField[] = [];
$: fieldApis = editorFields.filter(Boolean).map((field) => field.api);
const currentField = writable<EditorFieldAPI | null>(null);
const activeInput = writable<RichTextInputAPI | PlainTextInputAPI | null>(null);
const focusInRichText = writable<boolean>(false);
export const api = set(
Object.create(
{
currentField,
activeInput,
focusInRichText,
},
{
fields: { get: () => fieldApis },
}
)
);
onMount(() => {
document.addEventListener("visibilitychange", saveOnPageHide);
return () => document.removeEventListener("visibilitychange", saveOnPageHide);
});
onDestroy(() => deregisterSticky);
</script>
<NoteEditor>
<FieldsEditor>
<EditorToolbar {size} {wrap} {textColor} {highlightColor} />
{#if hint}
<Absolute bottom right --margin="10px">
<Notification>
<Badge --badge-color="tomato" --icon-align="top"
>{@html alertIcon}</Badge
>
<span>{@html hint}</span>
</Notification>
</Absolute>
{/if}
<Fields>
{#each fieldsData as field, index}
<EditorField
{field}
content={fieldStores[index]}
autofocus={index === focusTo}
bind:this={editorFields[index]}
on:focusin={() => {
$currentField = api.fields[index];
bridgeCommand(`focus:${index}`);
}}
on:focusout={() => {
$currentField = null;
bridgeCommand(
`blur:${index}:${getNoteId()}:${get(fieldStores[index])}`
);
}}
--label-color={cols[index] === "dupe"
? "var(--flag1-bg)"
: "transparent"}
>
<svelte:fragment slot="field-state">
<RichTextBadge bind:off={richTextsHidden[index]} />
<PlainTextBadge bind:off={plainTextsHidden[index]} />
{#if stickies}
<StickyBadge active={stickies[index]} {index} />
{/if}
</svelte:fragment>
<svelte:fragment slot="editing-inputs">
<DecoratedElements>
<RichTextInput
hidden={richTextsHidden[index]}
on:focusin={() => {
$focusInRichText = true;
$activeInput = richTextInputs[index].api;
}}
on:focusout={() => {
$focusInRichText = false;
$activeInput = null;
saveFieldNow();
}}
bind:this={richTextInputs[index]}
>
<ImageHandle />
<MathjaxHandle />
</RichTextInput>
<PlainTextInput
hidden={plainTextsHidden[index]}
on:focusin={() => {
$activeInput = plainTextInputs[index].api;
}}
on:focusout={() => {
$activeInput = null;
saveFieldNow();
}}
bind:this={plainTextInputs[index]}
/>
</DecoratedElements>
</svelte:fragment>
</EditorField>
{/each}
</Fields>
</FieldsEditor>
<TagEditor {size} {wrap} {tags} on:tagsupdate={saveTags} />
</NoteEditor>

View File

@ -1,14 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import WithContext from "../components/WithContext.svelte";
import { fieldFocusedKey, inCodableKey } from "./context-keys";
</script>
<WithContext key={fieldFocusedKey} let:context={fieldFocused}>
<WithContext key={inCodableKey} let:context={inCodable}>
<slot disabled={!fieldFocused || inCodable} />
</WithContext>
</WithContext>

View File

@ -0,0 +1,45 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Badge from "../components/Badge.svelte";
import * as tr from "../lib/ftl";
import { onMount } from "svelte";
import { htmlOn, htmlOff } from "./icons";
import { getEditorField } from "./EditorField.svelte";
import { registerShortcut, getPlatformString } from "../lib/shortcuts";
const editorField = getEditorField();
const keyCombination = "Control+Shift+X";
export let off = false;
$: icon = off ? htmlOff : htmlOn;
function toggle() {
off = !off;
}
onMount(() =>
registerShortcut(toggle, keyCombination, editorField.element as HTMLElement)
);
</script>
<span on:click|stopPropagation={toggle}>
<Badge
tooltip="{tr.editingHtmlEditor()} ({getPlatformString(keyCombination)})"
iconSize={80}>{@html icon}</Badge
>
</span>
<style lang="scss">
span {
opacity: 0.6;
&:hover {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,160 @@
<!--
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 { EditingInputAPI } from "./EditingArea.svelte";
export interface PlainTextInputAPI extends EditingInputAPI {
name: "plain-text";
moveCaretToEnd(): void;
toggle(): boolean;
surround(before: string, after: string): void;
}
</script>
<script lang="ts">
import CodeMirror from "./CodeMirror.svelte";
import type { CodeMirrorAPI } from "./CodeMirror.svelte";
import { tick, onMount } from "svelte";
import { writable } from "svelte/store";
import { getDecoratedElements } from "./DecoratedElements.svelte";
import { getEditingArea } from "./EditingArea.svelte";
import { htmlanki, baseOptions, gutterOptions } from "./code-mirror";
export let hidden = false;
const configuration = {
mode: htmlanki,
...baseOptions,
...gutterOptions,
};
const { editingInputs, content } = getEditingArea();
const decoratedElements = getDecoratedElements();
const code = writable($content);
function adjustInputHTML(html: string): string {
for (const component of decoratedElements) {
html = component.toUndecorated(html);
}
return html;
}
const parser = new DOMParser();
// TODO Expose this somehow
const parseStyle = "<style>anki-mathjax { white-space: pre; }</style>";
function parseAsHTML(html: string): string {
const doc = parser.parseFromString(parseStyle + html, "text/html");
const body = doc.body;
for (const script of body.getElementsByTagName("script")) {
script.remove();
}
for (const script of body.getElementsByTagName("link")) {
script.remove();
}
for (const style of body.getElementsByTagName("style")) {
style.remove();
}
return doc.body.innerHTML;
}
function adjustOutputHTML(html: string): string {
for (const component of decoratedElements) {
html = component.toStored(html);
}
return html;
}
let codeMirror: CodeMirrorAPI;
function focus(): void {
codeMirror?.editor.focus();
}
function refocus(): void {
(codeMirror?.editor as any).display.input.blur();
focus();
}
function moveCaretToEnd(): void {
codeMirror?.editor.setCursor(codeMirror.editor.lineCount(), 0);
}
function surround(before: string, after: string): void {
const selection = codeMirror?.editor.getSelection();
codeMirror?.editor.replaceSelection(before + selection + after);
}
export const api = {
name: "plain-text",
focus,
focusable: !hidden,
moveCaretToEnd,
refocus,
toggle(): boolean {
hidden = !hidden;
return hidden;
},
surround,
} as PlainTextInputAPI;
function pushUpdate(): void {
api.focusable = !hidden;
$editingInputs = $editingInputs;
}
function refresh() {
codeMirror.editor.refresh();
}
$: {
hidden;
tick().then(refresh);
pushUpdate();
}
onMount(() => {
$editingInputs.push(api);
$editingInputs = $editingInputs;
const unsubscribeFromEditingArea = content.subscribe((value: string): void => {
const adjusted = adjustInputHTML(value);
code.set(adjusted);
});
const unsubscribeToEditingArea = code.subscribe((value: string): void => {
const parsed = parseAsHTML(value);
content.set(adjustOutputHTML(parsed));
});
return () => {
unsubscribeFromEditingArea();
unsubscribeToEditingArea();
};
});
</script>
<div class:hidden on:focusin on:focusout>
<CodeMirror
{configuration}
{code}
bind:api={codeMirror}
on:focus={moveCaretToEnd}
on:change={({ detail: html }) => code.set(parseAsHTML(html))}
/>
</div>
<style lang="scss">
.hidden {
display: none;
}
</style>

View File

@ -0,0 +1,30 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Badge from "../components/Badge.svelte";
import { richTextOn, richTextOff } from "./icons";
export let off: boolean;
function toggle(): void {
off = !off;
}
$: icon = off ? richTextOff : richTextOn;
</script>
<span on:click|stopPropagation={toggle}>
<Badge iconSize={80}>{@html icon}</Badge>
</span>
<style lang="scss">
span {
opacity: 0.6;
&:hover {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,255 @@
<!--
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 CustomStyles from "./CustomStyles.svelte";
import type { EditingInputAPI } from "./EditingArea.svelte";
import contextProperty from "../sveltelib/context-property";
export interface RichTextInputAPI extends EditingInputAPI {
name: "rich-text";
moveCaretToEnd(): void;
refocus(): void;
toggle(): boolean;
surround(before: string, after: string): void;
preventResubscription(): () => void;
}
export interface RichTextInputContextAPI {
styles: CustomStyles;
container: HTMLElement;
api: RichTextInputAPI;
}
const key = Symbol("richText");
const [set, getRichTextInput, hasRichTextInput] =
contextProperty<RichTextInputContextAPI>(key);
export { getRichTextInput, hasRichTextInput };
</script>
<script lang="ts">
import RichTextStyles from "./RichTextStyles.svelte";
import SetContext from "./SetContext.svelte";
import ContentEditable from "../editable/ContentEditable.svelte";
import { onMount, getAllContexts } from "svelte";
import {
nodeIsElement,
nodeContainsInlineContent,
fragmentToString,
caretToEnd,
} from "../lib/dom";
import { getDecoratedElements } from "./DecoratedElements.svelte";
import { getEditingArea } from "./EditingArea.svelte";
import { promiseWithResolver } from "../lib/promise";
import { bridgeCommand } from "../lib/bridgecommand";
import { wrapInternal } from "../lib/wrap";
import { nodeStore } from "../sveltelib/node-store";
import type { DecoratedElement } from "../editable/decorated";
export let hidden: boolean;
const { content, editingInputs } = getEditingArea();
const decoratedElements = getDecoratedElements();
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);
}
function attachShadow(element: Element): void {
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");
}
richTextInput.addEventListener("paste", onPaste);
richTextInput.addEventListener("copy", onCutOrCopy);
richTextInput.addEventListener("cut", onCutOrCopy);
richTextResolve(richTextInput);
return {
destroy() {
richTextInput.removeEventListener("paste", onPaste);
richTextInput.removeEventListener("copy", onCutOrCopy);
richTextInput.removeEventListener("cut", onCutOrCopy);
},
};
}
import getDOMMirror from "../sveltelib/mirror-dom";
const { mirror, preventResubscription } = getDOMMirror();
function moveCaretToEnd() {
richTextPromise.then(caretToEnd);
}
const allContexts = getAllContexts();
function attachContentEditable(element: Element, { stylesDidLoad }): void {
stylesDidLoad.then(() => {
const contentEditable = new ContentEditable({
target: element.shadowRoot!,
props: {
nodes,
resolve,
mirror,
},
context: allContexts,
});
contentEditable.$on("focus", moveCaretToEnd);
});
}
export const api: RichTextInputAPI = {
name: "rich-text",
focus() {
richTextPromise.then((richText) => richText.focus());
},
refocus() {
richTextPromise.then((richText) => {
richText.blur();
richText.focus();
});
},
moveCaretToEnd,
focusable: !hidden,
toggle(): boolean {
hidden = !hidden;
return hidden;
},
surround(before: string, after: string) {
richTextPromise.then((richText) =>
wrapInternal(richText.getRootNode() as any, before, after, false)
);
},
preventResubscription,
};
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>
<RichTextStyles
color="white"
let:attachToShadow={attachStyles}
let:promise={stylesPromise}
let:stylesDidLoad
>
<div
class:hidden
use:attachShadow
use:attachStyles
use:attachContentEditable={{ stylesDidLoad }}
on:focusin
on:focusout
/>
<div class="editable-widgets">
{#await Promise.all([richTextPromise, stylesPromise]) then [container, styles]}
<SetContext setter={set} value={{ container, styles, api }}>
<slot />
</SetContext>
{/await}
</div>
</RichTextStyles>
<style lang="scss">
.hidden {
display: none;
}
</style>

View File

@ -0,0 +1,64 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import CustomStyles from "./CustomStyles.svelte";
import { promiseWithResolver } from "../lib/promise";
import type { StyleLinkType, StyleObject } from "./CustomStyles.svelte";
import { getContext } from "svelte";
import type { Readable } from "svelte/store";
import { fontFamilyKey, fontSizeKey, directionKey } from "../lib/context-keys";
const [promise, customStylesResolve] = promiseWithResolver<CustomStyles>();
const [userBaseStyle, userBaseResolve] = promiseWithResolver<StyleObject>();
const [userBaseRule, userBaseRuleResolve] = promiseWithResolver<CSSStyleRule>();
const stylesDidLoad: Promise<unknown> = Promise.all([
promise,
userBaseStyle,
userBaseRule,
]);
userBaseStyle.then((baseStyle: StyleObject) => {
const sheet = baseStyle.element.sheet as CSSStyleSheet;
const baseIndex = sheet.insertRule("anki-editable {}");
userBaseRuleResolve(sheet.cssRules[baseIndex] as CSSStyleRule);
});
export let color: string;
const fontFamily = getContext<Readable<string>>(fontFamilyKey);
const fontSize = getContext<Readable<number>>(fontSizeKey);
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey);
async function setStyling(property: string, value: unknown): Promise<void> {
const rule = await userBaseRule;
rule.style[property] = value;
}
$: setStyling("color", color);
$: setStyling("fontFamily", $fontFamily);
$: setStyling("fontSize", $fontSize + "px");
$: setStyling("direction", $direction);
const styles = [
{
id: "rootStyle",
type: "link" as "link",
href: "./_anki/css/editable-build.css",
} as StyleLinkType,
];
function attachToShadow(element: Element) {
const customStyles = new CustomStyles({
target: element.shadowRoot as any,
props: { styles },
});
customStyles.addStyleTag("userBase").then(userBaseResolve);
customStylesResolve(customStyles);
}
</script>
<slot {attachToShadow} {promise} {stylesDidLoad} />

View File

@ -0,0 +1,12 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
export let setter: (value: any) => void;
export let value: any;
setter(value);
</script>
<slot />

View File

@ -0,0 +1,47 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Badge from "../components/Badge.svelte";
import { onMount } from "svelte";
import { stickyOn, stickyOff } from "./icons";
import { getEditorField } from "./EditorField.svelte";
import * as tr from "../lib/ftl";
import { bridgeCommand } from "../lib/bridgecommand";
import { registerShortcut, getPlatformString } from "../lib/shortcuts";
export let active: boolean;
$: icon = active ? stickyOn : stickyOff;
const editorField = getEditorField();
const keyCombination = "F9";
export let index: number;
function toggleSticky() {
bridgeCommand(`toggleSticky:${index}`, (value: boolean) => {
active = value;
});
}
onMount(() => registerShortcut(toggleSticky, keyCombination, editorField.element));
</script>
<span on:click|stopPropagation={toggleSticky}>
<Badge
tooltip="{tr.editingToggleSticky()} ({getPlatformString(keyCombination)})"
widthMultiplier={0.7}>{@html icon}</Badge
>
</span>
<style lang="scss">
span {
opacity: 0.6;
&:hover {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,22 @@
<!--
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, onDestroy } from "svelte";
import { customStylesKey } from "./CustomStyles.svelte";
export let id: string;
export let href: string;
const { register, deregister } = getContext(customStylesKey);
function onLoad(event: Event): void {
const link = event.target! as HTMLLinkElement;
register(id, { element: link });
}
onDestroy(() => deregister(id));
</script>
<link {id} rel="stylesheet" {href} on:load={onLoad} />

24
ts/editor/StyleTag.svelte Normal file
View File

@ -0,0 +1,24 @@
<!--
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, onDestroy } from "svelte";
import { customStylesKey } from "./CustomStyles.svelte";
export let id: string;
const { register, deregister } = getContext(customStylesKey);
function onLoad(event: Event): void {
const style = event.target! as HTMLStyleElement;
register(id, { element: style });
}
onDestroy(() => deregister(id));
</script>
<!-- otherwise Svelte thinks it's a scoped style tag -->
{#if true}
<style {id} on:load={onLoad}></style>
{/if}

View File

@ -3,9 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import { tick } from "svelte";
import { isApplePlatform } from "../lib/platform";
import { bridgeCommand } from "../lib/bridgecommand";
import { createEventDispatcher, tick } from "svelte";
import type { Writable } from "svelte/store";
import StickyFooter from "../components/StickyFooter.svelte";
import TagOptionsBadge from "./TagOptionsBadge.svelte";
import TagEditMode from "./TagEditMode.svelte";
@ -24,24 +23,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { postRequest } from "../lib/postrequest";
import { execCommand } from "./helpers";
export let tags: TagType[] = [];
export let size: number;
export let wrap: boolean;
export let tags: Writable<string[]>;
export let size = isApplePlatform() ? 1.6 : 2.0;
export let wrap = true;
export function resetTags(names: string[]): void {
tags = names.map(replaceWithUnicodeSeparator).map(attachId);
let tagTypes: TagType[];
function tagsToTagTypes(tags: string[]): void {
tagTypes = tags.map(
(tag: string): TagType => attachId(replaceWithUnicodeSeparator(tag))
);
}
$: tagsToTagTypes($tags);
const dispatch = createEventDispatcher();
const noSuggestions = Promise.resolve([]);
let suggestionsPromise: Promise<string[]> = noSuggestions;
function saveTags(): void {
bridgeCommand(
`saveTags:${JSON.stringify(
tags.map((tag) => tag.name).map(replaceWithColons)
)}`
);
const tags = tagTypes.map((tag: TagType) => tag.name).map(replaceWithColons);
dispatch("tagsupdate", { tags });
suggestionsPromise = noSuggestions;
}
@ -68,7 +69,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const withoutSingleColonAtStartOrEnd = /^:?([^:].*?[^:]):?$/;
function updateSuggestions(): void {
const activeTag = tags[active!];
const activeTag = tagTypes[active!];
const activeName = activeTag.name;
autocompleteDisabled = activeName.length === 0;
@ -91,7 +92,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
function onAutocomplete(selected: string): void {
const activeTag = tags[active!];
const activeTag = tagTypes[active!];
activeName = selected ?? activeTag.name;
activeInput.setSelectionRange(Infinity, Infinity);
@ -99,7 +100,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
async function updateTagName(tag: TagType): Promise<void> {
tag.name = activeName;
tags = tags;
tagTypes = tagTypes;
await tick();
if (activeInput) {
@ -115,10 +116,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function appendEmptyTag(): void {
// used by tag badge and tag spacer
const lastTag = tags[tags.length - 1];
const lastTag = tagTypes[tagTypes.length - 1];
if (!lastTag || lastTag.name.length > 0) {
appendTagAndFocusAt(tags.length - 1, "");
appendTagAndFocusAt(tagTypes.length - 1, "");
}
const tagsHadFocus = active === null;
@ -130,18 +131,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
function appendTagAndFocusAt(index: number, name: string): void {
tags.splice(index + 1, 0, attachId(name));
tags = tags;
tagTypes.splice(index + 1, 0, attachId(name));
tagTypes = tagTypes;
setActiveAfterBlur(index + 1);
}
function isActiveNameUniqueAt(index: number): boolean {
const names = tags.map(getName);
const names = tagTypes.map(getName);
names.splice(index, 1);
const contained = names.indexOf(activeName);
if (contained >= 0) {
tags[contained >= index ? contained + 1 : contained].flash();
tagTypes[contained >= index ? contained + 1 : contained].flash();
return false;
}
@ -182,15 +183,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function insertTagKeepFocus(index: number): void {
if (isActiveNameUniqueAt(index)) {
tags.splice(index, 0, attachId(activeName));
tagTypes.splice(index, 0, attachId(activeName));
active!++;
tags = tags;
tagTypes = tagTypes;
}
}
function deleteTagAt(index: number): TagType {
const deleted = tags.splice(index, 1)[0];
tags = tags;
const deleted = tagTypes.splice(index, 1)[0];
tagTypes = tagTypes;
if (activeAfterBlur !== null && activeAfterBlur > index) {
activeAfterBlur--;
@ -204,7 +205,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
function isLast(index: number): boolean {
return index === tags.length - 1;
return index === tagTypes.length - 1;
}
function joinWithPreviousTag(index: number): void {
@ -215,7 +216,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const deleted = deleteTagAt(index - 1);
activeName = deleted.name + activeName;
active!--;
updateTagName(tags[active!]);
updateTagName(tagTypes[active!]);
}
function joinWithNextTag(index: number): void {
@ -225,7 +226,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const deleted = deleteTagAt(index + 1);
activeName = activeName + deleted.name;
updateTagName(tags[active!]);
updateTagName(tagTypes[active!]);
}
function moveToPreviousTag(index: number): void {
@ -254,7 +255,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
function deleteTagIfNotUnique(tag: TagType, index: number): void {
if (!tags.includes(tag)) {
if (!tagTypes.includes(tag)) {
// already deleted
return;
}
@ -307,8 +308,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let selectionFocus: number | null = null;
function select(index: number) {
tags[index].selected = !tags[index].selected;
tags = tags;
tagTypes[index].selected = !tagTypes[index].selected;
tagTypes = tagTypes;
selectionAnchor = index;
}
@ -325,14 +326,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const to = Math.max(selectionAnchor, selectionFocus);
for (let index = from; index <= to; index++) {
tags[index].selected = true;
tagTypes[index].selected = true;
}
tags = tags;
tagTypes = tagTypes;
}
function deselect() {
tags = tags.map((tag: TagType): TagType => ({ ...tag, selected: false }));
tagTypes = tagTypes.map(
(tag: TagType): TagType => ({ ...tag, selected: false })
);
selectionAnchor = null;
selectionFocus = null;
}
@ -361,12 +364,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
function selectAllTags() {
tags.forEach((tag) => (tag.selected = true));
tags = tags;
tagTypes.forEach((tag) => (tag.selected = true));
tagTypes = tagTypes;
}
function copySelectedTags() {
const content = tags
const content = tagTypes
.filter((tag) => tag.selected)
.map((tag) => replaceWithColons(tag.name))
.join("\n");
@ -375,7 +378,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
function deleteSelectedTags() {
tags.map((tag, index) => [tag.selected, index])
tagTypes
.map((tag, index) => [tag.selected, index])
.filter(([selected]) => selected)
.reverse()
.forEach(([, index]) => deleteTagAt(index as number));
@ -394,8 +398,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<StickyFooter bind:height class="d-flex">
{#if !wrap}
<TagOptionsBadge
--buttons-size="{size}rem"
showSelectionsOptions={tags.some((tag) => tag.selected)}
showSelectionsOptions={tagTypes.some((tag) => tag.selected)}
bind:badgeHeight
on:tagselectall={selectAllTags}
on:tagcopy={copySelectedTags}
@ -412,7 +415,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
{#if wrap}
<TagOptionsBadge
showSelectionsOptions={tags.some((tag) => tag.selected)}
showSelectionsOptions={tagTypes.some((tag) => tag.selected)}
bind:badgeHeight
on:tagselectall={selectAllTags}
on:tagcopy={copySelectedTags}
@ -421,13 +424,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/>
{/if}
{#each tags as tag, index (tag.id)}
<div
class="position-relative tag-margins"
class:hide-tag={index === active}
>
{#each tagTypes as tag, index (tag.id)}
<div class="position-relative" class:hide-tag={index === active}>
<TagEditMode
class="ms-0 tag-margins-inner"
class="ms-0"
name={index === active ? activeName : tag.name}
tooltip={tag.name}
active={index === active}
@ -499,7 +499,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:click={appendEmptyTag}
/>
<div class="position-relative tag-margins hide-tag zero-width-tag">
<div class="position-relative hide-tag zero-width-tag">
<!-- makes sure footer does not resize when adding first tag -->
<Tag>SPACER</Tag>
</div>
@ -522,14 +522,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
padding-right: 0 !important;
}
.tag-margins {
margin-bottom: 0.15rem;
:global(.tag-margins-inner) {
margin-right: 2px;
}
}
.adjust-position {
:global(.tag-input) {
/* recreates positioning of Tag component */

View File

@ -3,10 +3,6 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import * as tr from "../lib/ftl";
import { bridgeCommand } from "../lib/bridgecommand";
import { fieldFocusedKey, inCodableKey } from "./context-keys";
import ButtonGroup from "../components/ButtonGroup.svelte";
import ButtonGroupItem from "../components/ButtonGroupItem.svelte";
import IconButton from "../components/IconButton.svelte";
@ -14,16 +10,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import DropdownItem from "../components/DropdownItem.svelte";
import WithDropdown from "../components/WithDropdown.svelte";
import WithShortcut from "../components/WithShortcut.svelte";
import WithContext from "../components/WithContext.svelte";
import OnlyEditable from "./OnlyEditable.svelte";
import ClozeButton from "./ClozeButton.svelte";
import { getCurrentField, appendInParentheses } from "./helpers";
import * as tr from "../lib/ftl";
import { bridgeCommand } from "../lib/bridgecommand";
import { getNoteEditor } from "./OldEditorAdapter.svelte";
import { appendInParentheses } from "./helpers";
import { withButton } from "../components/helpers";
import { wrapCurrent } from "./wrap";
import { paperclipIcon, micIcon, functionIcon, xmlIcon } from "./icons";
import { paperclipIcon, micIcon, functionIcon } from "./icons";
export let api = {};
const { focusInRichText, activeInput } = getNoteEditor();
function onAttachment(): void {
bridgeCommand("attach");
@ -33,76 +30,60 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
bridgeCommand("record");
}
function onHtmlEdit() {
const currentField = getCurrentField();
if (currentField) {
currentField.toggleHtmlEdit();
}
}
$: disabled = !$focusInRichText;
</script>
<ButtonGroup {api}>
<ButtonGroupItem>
<WithShortcut shortcut={"F3"} let:createShortcut let:shortcutLabel>
<OnlyEditable let:disabled>
<IconButton
tooltip={appendInParentheses(
tr.editingAttachPicturesaudiovideo(),
shortcutLabel
)}
iconSize={70}
{disabled}
on:click={onAttachment}
on:mount={withButton(createShortcut)}
>
{@html paperclipIcon}
</IconButton>
</OnlyEditable>
<WithShortcut shortcut="F3" let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(
tr.editingAttachPicturesaudiovideo(),
shortcutLabel
)}
iconSize={70}
{disabled}
on:click={onAttachment}
on:mount={withButton(createShortcut)}
>
{@html paperclipIcon}
</IconButton>
</WithShortcut>
</ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"F5"} let:createShortcut let:shortcutLabel>
<OnlyEditable let:disabled>
<IconButton
tooltip={appendInParentheses(
tr.editingRecordAudio(),
shortcutLabel
)}
iconSize={70}
{disabled}
on:click={onRecord}
on:mount={withButton(createShortcut)}
>
{@html micIcon}
</IconButton>
</OnlyEditable>
<WithShortcut shortcut="F5" let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(tr.editingRecordAudio(), shortcutLabel)}
iconSize={70}
{disabled}
on:click={onRecord}
on:mount={withButton(createShortcut)}
>
{@html micIcon}
</IconButton>
</WithShortcut>
</ButtonGroupItem>
<ButtonGroupItem id="cloze">
<OnlyEditable>
<ClozeButton />
</OnlyEditable>
<ClozeButton />
</ButtonGroupItem>
<ButtonGroupItem>
<WithDropdown let:createDropdown>
<OnlyEditable let:disabled>
<IconButton {disabled} on:mount={withButton(createDropdown)}>
{@html functionIcon}
</IconButton>
</OnlyEditable>
<IconButton {disabled} on:mount={withButton(createDropdown)}>
{@html functionIcon}
</IconButton>
<DropdownMenu>
<WithShortcut
shortcut={"Control+M, M"}
shortcut="Control+M, M"
let:createShortcut
let:shortcutLabel
>
<DropdownItem
on:click={() =>
wrapCurrent(
$activeInput?.surround(
"<anki-mathjax focusonmount>",
"</anki-mathjax>"
)}
@ -114,13 +95,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut>
<WithShortcut
shortcut={"Control+M, E"}
shortcut="Control+M, E"
let:createShortcut
let:shortcutLabel
>
<DropdownItem
on:click={() =>
wrapCurrent(
$activeInput?.surround(
'<anki-mathjax block="true" focusonmount>',
"</anki-matjax>"
)}
@ -132,13 +113,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut>
<WithShortcut
shortcut={"Control+M, C"}
shortcut="Control+M, C"
let:createShortcut
let:shortcutLabel
>
<DropdownItem
on:click={() =>
wrapCurrent(
$activeInput?.surround(
"<anki-mathjax focusonmount>\\ce{",
"}</anki-mathjax>"
)}
@ -150,12 +131,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut>
<WithShortcut
shortcut={"Control+T, T"}
shortcut="Control+T, T"
let:createShortcut
let:shortcutLabel
>
<DropdownItem
on:click={() => wrapCurrent("[latex]", "[/latex]")}
on:click={() => $activeInput?.surround("[latex]", "[/latex]")}
on:mount={withButton(createShortcut)}
>
{tr.editingLatex()}
@ -164,12 +145,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut>
<WithShortcut
shortcut={"Control+T, E"}
shortcut="Control+T, E"
let:createShortcut
let:shortcutLabel
>
<DropdownItem
on:click={() => wrapCurrent("[$]", "[/$]")}
on:click={() => $activeInput?.surround("[$]", "[/$]")}
on:mount={withButton(createShortcut)}
>
{tr.editingLatexEquation()}
@ -178,12 +159,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut>
<WithShortcut
shortcut={"Control+T, M"}
shortcut="Control+T, M"
let:createShortcut
let:shortcutLabel
>
<DropdownItem
on:click={() => wrapCurrent("[$$]", "[/$$]")}
on:click={() => $activeInput?.surround("[$$]", "[/$$]")}
on:mount={withButton(createShortcut)}
>
{tr.editingLatexMathEnv()}
@ -193,30 +174,4 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</DropdownMenu>
</WithDropdown>
</ButtonGroupItem>
<ButtonGroupItem>
<WithContext key={fieldFocusedKey} let:context={fieldFocused}>
<WithContext key={inCodableKey} let:context={inCodable}>
<WithShortcut
shortcut={"Control+Shift+X"}
let:createShortcut
let:shortcutLabel
>
<IconButton
tooltip={appendInParentheses(
tr.editingHtmlEditor(),
shortcutLabel
)}
iconSize={70}
active={inCodable}
disabled={!fieldFocused}
on:click={onHtmlEdit}
on:mount={withButton(createShortcut)}
>
{@html xmlIcon}
</IconButton>
</WithShortcut>
</WithContext>
</WithContext>
</ButtonGroupItem>
</ButtonGroup>

View File

@ -1,83 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
import { CodeMirror, htmlanki, baseOptions, gutterOptions } from "./codeMirror";
import { inCodable } from "./toolbar";
const codeMirrorOptions = {
mode: htmlanki,
...baseOptions,
...gutterOptions,
};
const parser = new DOMParser();
const parseStyle = "<style>anki-mathjax { white-space: pre; }</style>";
function parseHTML(html: string): string {
const doc = parser.parseFromString(`${parseStyle}${html}`, "text/html");
return doc.body.innerHTML;
}
export class Codable extends HTMLTextAreaElement {
codeMirror: CodeMirror.EditorFromTextArea | undefined;
get active(): boolean {
return Boolean(this.codeMirror);
}
set fieldHTML(content: string) {
if (this.active) {
this.codeMirror?.setValue(content);
} else {
this.value = content;
}
}
get fieldHTML(): string {
return parseHTML(this.active ? this.codeMirror!.getValue() : this.value);
}
connectedCallback(): void {
this.setAttribute("hidden", "");
}
setup(html: string): void {
this.fieldHTML = html;
this.codeMirror = CodeMirror.fromTextArea(this, codeMirrorOptions);
this.codeMirror.on("blur", () => inCodable.set(false));
}
teardown(): string {
this.codeMirror!.toTextArea();
this.codeMirror = undefined;
return this.fieldHTML;
}
focus(): void {
this.codeMirror!.focus();
inCodable.set(true);
}
caretToEnd(): void {
this.codeMirror!.setCursor(this.codeMirror!.lineCount(), 0);
}
surroundSelection(before: string, after: string): void {
const selection = this.codeMirror!.getSelection();
this.codeMirror!.replaceSelection(before + selection + after);
}
onEnter(): void {
/* default */
}
onPaste(): void {
/* default */
}
}
customElements.define("anki-codable", Codable, { extends: "textarea" });

View File

@ -24,21 +24,18 @@ export const htmlanki = {
},
};
const noop = (): void => {
/* noop */
};
export const baseOptions = {
export const baseOptions: CodeMirror.EditorConfiguration = {
theme: "monokai",
lineWrapping: true,
matchTags: { bothTags: true },
autoCloseTags: true,
extraKeys: { Tab: noop, "Shift-Tab": noop },
extraKeys: { Tab: false, "Shift-Tab": false },
tabindex: 0,
viewportMargin: Infinity,
lineWiseCopyCut: false,
};
export const gutterOptions = {
export const gutterOptions: CodeMirror.EditorConfiguration = {
gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
lineNumbers: true,
foldGutter: true,

View File

@ -1,296 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/// <reference types="../lib/shadow-dom" />
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
@typescript-eslint/no-explicit-any: "off",
*/
import ImageHandle from "./ImageHandle.svelte";
import MathjaxHandle from "./MathjaxHandle.svelte";
import type { EditableContainer } from "../editable/editable-container";
import type { Editable } from "../editable/editable";
import type { Codable } from "./codable";
import { updateActiveButtons } from "./toolbar";
import { bridgeCommand } from "./lib";
import { onInput, onKey, onKeyUp } from "./input-handlers";
import { deferFocusDown, saveFieldIfFieldChanged } from "./focus-handlers";
import { nightModeKey } from "../components/context-keys";
import { decoratedComponents } from "../editable/decorated";
function onCutOrCopy(): void {
bridgeCommand("cutOrCopy");
}
export class EditingArea extends HTMLDivElement {
imageHandle: Promise<ImageHandle>;
mathjaxHandle: MathjaxHandle;
editableContainer: EditableContainer;
editable: Editable;
codable: Codable;
constructor() {
super();
this.className = "field";
this.editableContainer = document.createElement("div", {
is: "anki-editable-container",
}) as EditableContainer;
this.editable = document.createElement("anki-editable") as Editable;
this.editableContainer.shadowRoot!.appendChild(this.editable);
this.appendChild(this.editableContainer);
const context = new Map();
context.set(
nightModeKey,
document.documentElement.classList.contains("night-mode")
);
let imageHandleResolve: (value: ImageHandle) => void;
this.imageHandle = new Promise<ImageHandle>(
(resolve) => (imageHandleResolve = resolve)
);
this.editableContainer.imagePromise.then(() =>
imageHandleResolve(
new ImageHandle({
target: this,
anchor: this.editableContainer,
props: {
container: this.editable,
sheet: this.editableContainer.imageStyle.sheet,
},
context,
} as any)
)
);
this.mathjaxHandle = new MathjaxHandle({
target: this,
anchor: this.editableContainer,
props: {
container: this.editable,
},
context,
} as any);
this.codable = document.createElement("textarea", {
is: "anki-codable",
}) as Codable;
this.appendChild(this.codable);
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onKey = this.onKey.bind(this);
this.onPaste = this.onPaste.bind(this);
this.showHandles = this.showHandles.bind(this);
}
get activeInput(): Editable | Codable {
return this.codable.active ? this.codable : this.editable;
}
get ord(): number {
return Number(this.getAttribute("ord"));
}
set fieldHTML(content: string) {
this.imageHandle.then(() => {
let result = content;
for (const component of decoratedComponents) {
result = component.toUndecorated(result);
}
this.activeInput.fieldHTML = result;
});
}
get fieldHTML(): string {
let result = this.activeInput.fieldHTML;
for (const component of decoratedComponents) {
result = component.toStored(result);
}
return result;
}
connectedCallback(): void {
this.addEventListener("keydown", this.onKey);
this.addEventListener("keyup", onKeyUp);
this.addEventListener("input", onInput);
this.addEventListener("focusin", this.onFocus);
this.addEventListener("focusout", this.onBlur);
this.addEventListener("paste", this.onPaste);
this.addEventListener("copy", onCutOrCopy);
this.addEventListener("oncut", onCutOrCopy);
this.addEventListener("mouseup", updateActiveButtons);
this.editable.addEventListener("click", this.showHandles);
}
disconnectedCallback(): void {
this.removeEventListener("keydown", this.onKey);
this.removeEventListener("keyup", onKeyUp);
this.removeEventListener("input", onInput);
this.removeEventListener("focusin", this.onFocus);
this.removeEventListener("focusout", this.onBlur);
this.removeEventListener("paste", this.onPaste);
this.removeEventListener("copy", onCutOrCopy);
this.removeEventListener("oncut", onCutOrCopy);
this.removeEventListener("mouseup", updateActiveButtons);
this.editable.removeEventListener("click", this.showHandles);
}
initialize(color: string, content: string): void {
this.editableContainer.stylePromise.then(() => {
this.setBaseColor(color);
this.fieldHTML = content;
});
}
setBaseColor(color: string): void {
this.editableContainer.setBaseColor(color);
}
quoteFontFamily(fontFamily: string): string {
// generic families (e.g. sans-serif) must not be quoted
if (!/^[-a-z]+$/.test(fontFamily)) {
fontFamily = `"${fontFamily}"`;
}
return fontFamily;
}
setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
this.editableContainer.setBaseStyling(
this.quoteFontFamily(fontFamily),
fontSize,
direction
);
}
isRightToLeft(): boolean {
return this.editableContainer.isRightToLeft();
}
focus(): void {
this.activeInput.focus();
}
blur(): void {
this.activeInput.blur();
}
caretToEnd(): void {
this.activeInput.caretToEnd();
}
hasFocus(): boolean {
return document.activeElement?.closest(".field") === this;
}
getSelection(): Selection {
const root = this.activeInput.getRootNode() as Document | ShadowRoot;
return root.getSelection()!;
}
surroundSelection(before: string, after: string): void {
this.activeInput.surroundSelection(before, after);
}
onFocus(): void {
deferFocusDown(this);
}
onBlur(event: FocusEvent): void {
saveFieldIfFieldChanged(this, event.relatedTarget as Element);
}
onEnter(event: KeyboardEvent): void {
this.activeInput.onEnter(event);
}
onKey(event: KeyboardEvent): void {
this.resetHandles();
onKey(event);
}
onPaste(event: ClipboardEvent): void {
this.resetHandles();
this.activeInput.onPaste(event);
}
resetHandles(): Promise<void> {
const promise = this.imageHandle.then((imageHandle) =>
(imageHandle as any).$set({
activeImage: null,
})
);
(this.mathjaxHandle as any).$set({
activeImage: null,
});
return promise;
}
async showHandles(event: MouseEvent): Promise<void> {
if (event.target instanceof HTMLImageElement) {
const image = event.target as HTMLImageElement;
await this.resetHandles();
if (!image.dataset.anki) {
await this.imageHandle.then((imageHandle) =>
(imageHandle as any).$set({
activeImage: image,
isRtl: this.isRightToLeft(),
})
);
} else if (image.dataset.anki === "mathjax") {
(this.mathjaxHandle as any).$set({
activeImage: image,
isRtl: this.isRightToLeft(),
});
}
} else {
await this.resetHandles();
}
}
toggleHtmlEdit(): void {
const hadFocus = this.hasFocus();
if (this.codable.active) {
this.fieldHTML = this.codable.teardown();
this.editable.hidden = false;
} else {
this.resetHandles();
this.editable.hidden = true;
this.codable.setup(this.editable.fieldHTML);
}
if (hadFocus) {
this.focus();
this.caretToEnd();
}
}
/**
* @deprecated Use focus instead
*/
focusEditable(): void {
focus();
}
/**
* @deprecated Use blur instead
*/
blurEditable(): void {
blur();
}
}
customElements.define("anki-editing-area", EditingArea, { extends: "div" });

View File

@ -0,0 +1,55 @@
/* Copyright: Ankitects Pty Ltd and contributors
* License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
@use "base";
@use "scrollbar";
@use "button-mixins";
html,
body {
height: 100%;
}
.nightMode {
@include scrollbar.night-mode;
}
#dupes,
#cloze-hint {
position: sticky;
bottom: 0;
text-align: center;
background-color: var(--window-bg);
a {
color: var(--link);
}
}
.icon > svg {
fill: var(--text-fg);
}
.pin-icon {
cursor: pointer;
&.is-inactive {
opacity: 0.2;
}
&.icon--hover {
opacity: 0.5;
}
}
/* CodeMirror */
@import "codemirror/lib/codemirror";
@import "codemirror/theme/monokai";
@import "codemirror/addon/fold/foldgutter";
.CodeMirror {
height: auto;
border-radius: 0 0 5px 5px;
padding: 6px 0;
}

View File

@ -1,69 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { EditingArea } from "./editing-area";
import type { LabelContainer } from "./label-container";
export class EditorField extends HTMLDivElement {
labelContainer: LabelContainer;
editingArea: EditingArea;
constructor() {
super();
this.className = "editorfield";
this.labelContainer = document.createElement("div", {
is: "anki-label-container",
}) as LabelContainer;
this.appendChild(this.labelContainer);
this.editingArea = document.createElement("div", {
is: "anki-editing-area",
}) as EditingArea;
this.appendChild(this.editingArea);
this.focusIfNotFocused = this.focusIfNotFocused.bind(this);
}
static get observedAttributes(): string[] {
return ["ord"];
}
set ord(n: number) {
this.setAttribute("ord", String(n));
}
focusIfNotFocused(): void {
if (!this.editingArea.hasFocus()) {
this.editingArea.focus();
this.editingArea.caretToEnd();
}
}
connectedCallback(): void {
this.addEventListener("mousedown", this.focusIfNotFocused);
}
disconnectedCallback(): void {
this.removeEventListener("mousedown", this.focusIfNotFocused);
}
attributeChangedCallback(name: string, _oldValue: string, newValue: string): void {
switch (name) {
case "ord":
this.editingArea.setAttribute("ord", newValue);
this.labelContainer.setAttribute("ord", newValue);
}
}
initialize(label: string, color: string, content: string): void {
this.labelContainer.initialize(label);
this.editingArea.initialize(color, content);
}
setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
this.editingArea.setBaseStyling(fontFamily, fontSize, direction);
}
}
customElements.define("anki-editor-field", EditorField, { extends: "div" });

View File

@ -1,109 +0,0 @@
/* Copyright: Ankitects Pty Ltd and contributors
* License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
@use "sass/base";
@use "sass/scrollbar";
@use "sass/button-mixins";
html,
body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
}
.nightMode {
@include scrollbar.night-mode;
}
#fields {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-x: hidden;
margin: 3px 0;
}
.editorfield {
margin: 3px;
border-radius: 5px;
border: 1px solid var(--border-color);
--border-color: var(--border);
&:focus-within {
box-shadow: 0 0 0 3px var(--focus-shadow);
--border-color: var(--focus-border);
}
}
/* editing-area */
.field {
position: relative;
background: var(--frame-bg);
border-radius: 0 0 5px 5px;
&.dupe {
// this works around the background colour persisting in copy+paste
// (https://github.com/ankitects/anki/pull/1278)
background-image: linear-gradient(var(--flag1-bg), var(--flag1-bg));
}
}
.fname {
border-width: 0 0 1px;
border-style: dashed;
border-color: var(--border-color);
border-radius: 5px 5px 0 0;
padding: 0px 6px;
}
.fieldname {
user-select: none;
}
#dupes,
#cloze-hint {
position: sticky;
bottom: 0;
text-align: center;
background-color: var(--window-bg);
a {
color: var(--link);
}
}
.icon > svg {
fill: var(--text-fg);
}
.pin-icon {
cursor: pointer;
&.is-inactive {
opacity: 0.2;
}
&.icon--hover {
opacity: 0.5;
}
}
@import "codemirror/lib/codemirror";
@import "codemirror/theme/monokai";
@import "codemirror/addon/fold/foldgutter";
.CodeMirror {
height: auto;
border-radius: 0 0 5px 5px;
padding: 6px 0;
}

View File

@ -1,41 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
import { fieldFocused } from "./toolbar";
import type { EditingArea } from "./editing-area";
import { saveField } from "./saving";
import { bridgeCommand } from "./lib";
import { getCurrentField } from "./helpers";
export function deferFocusDown(editingArea: EditingArea): void {
editingArea.focus();
editingArea.caretToEnd();
if (editingArea.getSelection().anchorNode === null) {
// selection is not inside editable after focusing
editingArea.caretToEnd();
}
bridgeCommand(`focus:${editingArea.ord}`);
fieldFocused.set(true);
}
export function saveFieldIfFieldChanged(
editingArea: EditingArea,
focusTo: Element | null
): void {
const fieldChanged =
editingArea !== getCurrentField() && !editingArea.contains(focusTo);
saveField(editingArea, fieldChanged ? "blur" : "key");
fieldFocused.set(false);
if (fieldChanged) {
editingArea.resetHandles();
}
}

View File

@ -1,12 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { EditingArea } from "./editing-area";
export function getCurrentField(): EditingArea | null {
return document.activeElement?.closest(".field") ?? null;
}
export function appendInParentheses(text: string, appendix: string): string {
return `${text} (${appendix})`;
}

View File

@ -31,7 +31,6 @@ export { default as paperclipIcon } from "@mdi/svg/svg/paperclip.svg";
export { default as micIcon } from "bootstrap-icons/icons/mic.svg";
export { default as ellipseIcon } from "@mdi/svg/svg/contain.svg";
export { default as functionIcon } from "@mdi/svg/svg/function-variant.svg";
export { default as xmlIcon } from "@mdi/svg/svg/xml.svg";
export { default as tagIcon } from "@mdi/svg/svg/tag.svg";
export { default as addTagIcon } from "@mdi/svg/svg/tag-plus.svg";
@ -41,13 +40,13 @@ export { default as deleteIcon } from "bootstrap-icons/icons/x.svg";
export const arrowIcon =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="transparent" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2 5l6 6 6-6"/></svg>';
// image handle
export { default as floatNoneIcon } from "@mdi/svg/svg/format-float-none.svg";
export { default as floatLeftIcon } from "@mdi/svg/svg/format-float-left.svg";
export { default as floatRightIcon } from "@mdi/svg/svg/format-float-right.svg";
export { default as alertIcon } from "@mdi/svg/svg/alert.svg";
export { default as sizeActual } from "@mdi/svg/svg/image-size-select-actual.svg";
export { default as sizeMinimized } from "@mdi/svg/svg/image-size-select-large.svg";
export { default as richTextOn } from "@mdi/svg/svg/eye-outline.svg";
export { default as richTextOff } from "@mdi/svg/svg/eye-off-outline.svg";
export { default as inlineIcon } from "@mdi/svg/svg/format-wrap-square.svg";
export { default as blockIcon } from "@mdi/svg/svg/format-wrap-top-bottom.svg";
export { default as htmlOn } from "@mdi/svg/svg/code-tags.svg";
export { default as htmlOff } from "@mdi/svg/svg/xml.svg";
export { default as stickyOn } from "@mdi/svg/svg/pin-outline.svg";
export { default as stickyOff } from "@mdi/svg/svg/pin-off-outline.svg";

View File

@ -3,21 +3,25 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import * as tr from "../lib/ftl";
import * as tr from "../../lib/ftl";
import type { Readable } from "svelte/store";
import { getContext } from "svelte";
import { directionKey } from "../../lib/context-keys";
import ButtonGroup from "../components/ButtonGroup.svelte";
import ButtonGroupItem from "../components/ButtonGroupItem.svelte";
import IconButton from "../components/IconButton.svelte";
import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
import IconButton from "../../components/IconButton.svelte";
import { createEventDispatcher } from "svelte";
import { floatNoneIcon, floatLeftIcon, floatRightIcon } from "./icons";
export let image: HTMLImageElement;
export let isRtl: boolean;
const [inlineStartIcon, inlineEndIcon] = isRtl
? [floatRightIcon, floatLeftIcon]
: [floatLeftIcon, floatRightIcon];
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey);
const [inlineStartIcon, inlineEndIcon] =
$direction === "ltr"
? [floatLeftIcon, floatRightIcon]
: [floatRightIcon, floatLeftIcon];
const dispatch = createEventDispatcher();
</script>
@ -27,7 +31,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<IconButton
tooltip={tr.editingFloatLeft()}
active={image.style.float === "left"}
flipX={isRtl}
flipX={$direction === "rtl"}
on:click={() => {
image.style.float = "left";
setTimeout(() => dispatch("update"));
@ -39,7 +43,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<IconButton
tooltip={tr.editingFloatNone()}
active={image.style.float === "" || image.style.float === "none"}
flipX={isRtl}
flipX={$direction === "rtl"}
on:click={() => {
image.style.removeProperty("float");
@ -56,7 +60,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<IconButton
tooltip={tr.editingFloatRight()}
active={image.style.float === "right"}
flipX={isRtl}
flipX={$direction === "rtl"}
on:click={() => {
image.style.float = "right";
setTimeout(() => dispatch("update"));

View File

@ -3,24 +3,52 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import WithDropdown from "../components/WithDropdown.svelte";
import ButtonDropdown from "../components/ButtonDropdown.svelte";
import Item from "../components/Item.svelte";
import WithDropdown from "../../components/WithDropdown.svelte";
import ButtonDropdown from "../../components/ButtonDropdown.svelte";
import Item from "../../components/Item.svelte";
import HandleBackground from "../HandleBackground.svelte";
import HandleSelection from "../HandleSelection.svelte";
import HandleControl from "../HandleControl.svelte";
import HandleLabel from "../HandleLabel.svelte";
import WithImageConstrained from "./WithImageConstrained.svelte";
import HandleBackground from "./HandleBackground.svelte";
import HandleSelection from "./HandleSelection.svelte";
import HandleControl from "./HandleControl.svelte";
import HandleLabel from "./HandleLabel.svelte";
import ImageHandleFloatButtons from "./ImageHandleFloatButtons.svelte";
import ImageHandleSizeSelect from "./ImageHandleSizeSelect.svelte";
import FloatButtons from "./FloatButtons.svelte";
import SizeSelect from "./SizeSelect.svelte";
import { onDestroy } from "svelte";
import { tick, onDestroy } from "svelte";
import type { StyleObject } from "../CustomStyles.svelte";
import { getRichTextInput } from "../RichTextInput.svelte";
export let activeImage: HTMLImageElement | null = null;
export let container: HTMLElement;
export let sheet: CSSStyleSheet;
export let isRtl: boolean = false;
const { container, styles } = getRichTextInput();
const sheetPromise = styles
.addStyleTag("imageOverlay")
.then((styleObject: StyleObject) => styleObject.element.sheet!);
let activeImage: HTMLImageElement | null = null;
async function resetHandle(): Promise<void> {
activeImage = null;
await tick();
}
async function maybeShowHandle(event: Event): Promise<void> {
await resetHandle();
if (event.target instanceof HTMLImageElement) {
const image = event.target;
if (!image.dataset.anki) {
activeImage = image;
}
}
}
container.addEventListener("click", maybeShowHandle);
container.addEventListener("blur", resetHandle);
container.addEventListener("key", resetHandle);
container.addEventListener("paste", resetHandle);
$: naturalWidth = activeImage?.naturalWidth;
$: naturalHeight = activeImage?.naturalHeight;
@ -56,7 +84,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let updateSelection: () => Promise<void>;
async function updateSizesWithDimensions() {
await updateSelection();
await updateSelection?.();
updateDimensions();
}
@ -130,22 +158,30 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
activeImage!.width = width;
}
onDestroy(() => resizeObserver.disconnect());
onDestroy(() => {
resizeObserver.disconnect();
container.removeEventListener("click", maybeShowHandle);
container.removeEventListener("blur", resetHandle);
container.removeEventListener("key", resetHandle);
container.removeEventListener("paste", resetHandle);
});
</script>
{#if sheet}
<WithDropdown
drop="down"
autoOpen={true}
autoClose={false}
distance={3}
let:createDropdown
let:dropdownObject
>
<WithDropdown
drop="down"
autoOpen={true}
autoClose={false}
distance={3}
let:createDropdown
let:dropdownObject
>
{#await sheetPromise then sheet}
<WithImageConstrained
{sheet}
{container}
{activeImage}
maxWidth={250}
maxHeight={125}
on:update={() => {
updateSizesWithDimensions();
dropdownObject.update();
@ -162,7 +198,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
<HandleBackground on:dblclick={toggleActualSize} />
<HandleLabel {isRtl} on:mount={updateDimensions}>
<HandleLabel on:mount={updateDimensions}>
<span>{actualWidth}&times;{actualHeight}</span>
{#if customDimensions}
<span>(Original: {naturalWidth}&times;{naturalHeight})</span
@ -187,25 +223,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}}
/>
</HandleSelection>
<ButtonDropdown>
<div on:click={updateSizesWithDimensions}>
<Item>
<ImageHandleFloatButtons
image={activeImage}
{isRtl}
on:update={dropdownObject.update}
/>
</Item>
<Item>
<ImageHandleSizeSelect
{active}
{isRtl}
on:click={toggleActualSize}
/>
</Item>
</div>
<ButtonDropdown on:click={updateSizesWithDimensions}>
<Item>
<FloatButtons
image={activeImage}
on:update={dropdownObject.update}
/>
</Item>
<Item>
<SizeSelect {active} on:click={toggleActualSize} />
</Item>
</ButtonDropdown>
{/if}
</WithImageConstrained>
</WithDropdown>
{/if}
{/await}
</WithDropdown>

View File

@ -0,0 +1,33 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import * as tr from "../../lib/ftl";
import type { Readable } from "svelte/store";
import { getContext } from "svelte";
import { directionKey } from "../../lib/context-keys";
import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
import IconButton from "../../components/IconButton.svelte";
import { sizeActual, sizeMinimized } from "./icons";
export let active: boolean;
$: icon = active ? sizeActual : sizeMinimized;
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey);
</script>
<ButtonGroup size={1.6}>
<ButtonGroupItem>
<IconButton
{active}
flipX={$direction === "rtl"}
tooltip={tr.editingActualSize()}
on:click>{@html icon}</IconButton
>
</ButtonGroupItem>
</ButtonGroup>

View File

@ -4,7 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import { createEventDispatcher, onDestroy } from "svelte";
import { nodeIsElement } from "../lib/dom";
import { nodeIsElement } from "../../lib/dom";
export let activeImage: HTMLImageElement | null;
export let container: HTMLElement;
@ -23,8 +23,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
}
export let maxWidth = 250;
export let maxHeight = 125;
export let maxWidth: number;
export let maxHeight: number;
$: restrictionAspectRatio = maxWidth / maxHeight;
@ -55,7 +55,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return createPathRecursive([], element).join(" > ");
}
export const images: HTMLImageElement[] = [];
const images: HTMLImageElement[] = [];
$: for (const [index, image] of images.entries()) {
const rule = sheet.cssRules[index] as CSSStyleRule;
@ -132,12 +132,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
function addImageOnLoad(image: HTMLImageElement): void {
if (image.complete && image.naturalWidth !== 0 && image.naturalHeight !== 0) {
if (image.complete && image.naturalWidth > 0 && image.naturalHeight > 0) {
addImage(image);
} else {
image.addEventListener("load", () => {
addImage(image);
});
image.addEventListener("load", () => addImage(image));
}
}
@ -169,10 +167,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
});
$: if (container) {
mutationObserver.observe(container, { childList: true, subtree: true });
mutationObserver.observe(container, {
childList: true,
subtree: true,
});
for (const image of filterImages([...container.childNodes])) {
addImageOnLoad(image);
}
onDestroy(() => mutationObserver.disconnect());
export function toggleActualSize() {
const index = images.indexOf(activeImage!);
if (index === -1) {
@ -190,8 +195,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
dispatch("update", active);
}
onDestroy(() => mutationObserver.disconnect());
</script>
{#if activeImage}

View File

@ -0,0 +1,9 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export { default as floatNoneIcon } from "@mdi/svg/svg/format-float-none.svg";
export { default as floatLeftIcon } from "@mdi/svg/svg/format-float-left.svg";
export { default as floatRightIcon } from "@mdi/svg/svg/format-float-right.svg";
export { default as sizeActual } from "@mdi/svg/svg/image-size-select-actual.svg";
export { default as sizeMinimized } from "@mdi/svg/svg/image-size-select-large.svg";

View File

@ -1,5 +1,4 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const fieldFocusedKey = Symbol("fieldFocused");
export const inCodableKey = Symbol("inCodable");
export { default as ImageHandle } from "./ImageHandle.svelte";

View File

@ -6,42 +6,36 @@
@typescript-eslint/no-explicit-any: "off",
*/
import { filterHTML } from "../html-filter";
import { updateAllState } from "../components/WithState.svelte";
import { noop } from "../lib/functional";
export const $editorToolbar = new Promise(noop);
export function pasteHTML(
html: string,
internal: boolean,
extendedMode: boolean
): void {
html = filterHTML(html, internal, extendedMode);
if (html !== "") {
setFormat("inserthtml", html);
}
}
export function setFormat(cmd: string, arg?: string, _nosave = false): void {
document.execCommand(cmd, false, arg);
updateAllState(new Event(cmd));
}
import "../sveltelib/export-runtime";
import "../lib/register-package";
import type EditorToolbar from "./EditorToolbar.svelte";
import type TagEditor from "./TagEditor.svelte";
import { filterHTML } from "../html-filter";
import { setupI18n, ModuleName } from "../lib/i18n";
import { isApplePlatform } from "../lib/platform";
import { registerShortcut } from "../lib/shortcuts";
import { bridgeCommand } from "../lib/bridgecommand";
import { updateActiveButtons } from "./toolbar";
import { saveField } from "./saving";
import "./fields.css";
import "./label-container";
import "./codable";
import "./editor-field";
import type { EditorField } from "./editor-field";
import { EditingArea } from "./editing-area";
import "../editable/editable-container";
import "../editable/editable";
import "../editable/mathjax-component";
import { initToolbar, fieldFocused } from "./toolbar";
import { initTagEditor } from "./tag-editor";
import { getCurrentField } from "./helpers";
export { setNoteId, getNoteId } from "./note-id";
export { saveNow } from "./saving";
export { wrap, wrapIntoText } from "./wrap";
export { editorToolbar } from "./toolbar";
export { activateStickyShortcuts } from "./label-container";
export { getCurrentField } from "./helpers";
export { components } from "./Components.svelte";
declare global {
interface Selection {
@ -56,148 +50,6 @@ if (isApplePlatform()) {
registerShortcut(() => bridgeCommand("paste"), "Control+Shift+V");
}
export function focusField(n: number): void {
const field = getEditorField(n);
if (field) {
field.editingArea.focus();
field.editingArea.caretToEnd();
updateActiveButtons(new Event("manualfocus"));
}
}
export function focusIfField(x: number, y: number): boolean {
const elements = document.elementsFromPoint(x, y);
for (let i = 0; i < elements.length; i++) {
const elem = elements[i] as EditingArea;
if (elem instanceof EditingArea) {
elem.focus();
return true;
}
}
return false;
}
export function pasteHTML(
html: string,
internal: boolean,
extendedMode: boolean
): void {
html = filterHTML(html, internal, extendedMode);
if (html !== "") {
setFormat("inserthtml", html);
}
}
function adjustFieldAmount(amount: number): void {
const fieldsContainer = document.getElementById("fields")!;
while (fieldsContainer.childElementCount < amount) {
const newField = document.createElement("div", {
is: "anki-editor-field",
}) as EditorField;
newField.ord = fieldsContainer.childElementCount;
fieldsContainer.appendChild(newField);
}
while (fieldsContainer.childElementCount > amount) {
fieldsContainer.removeChild(fieldsContainer.lastElementChild as Node);
}
}
export function getEditorField(n: number): EditorField | null {
const fields = document.getElementById("fields")!.children;
return (fields[n] as EditorField) ?? null;
}
/// forEachEditorFieldAndProvidedValue:
/// Values should be a list with the same length as the
/// number of fields. Func will be called with each field and
/// value in turn.
export function forEditorField<T>(
values: T[],
func: (field: EditorField, value: T) => void
): void {
const fields = document.getElementById("fields")!.children;
for (let i = 0; i < fields.length; i++) {
const field = fields[i] as EditorField;
func(field, values[i]);
}
}
export function setFields(fields: [string, string][]): void {
// webengine will include the variable after enter+backspace
// if we don't convert it to a literal colour
const color = window
.getComputedStyle(document.documentElement)
.getPropertyValue("--text-fg");
adjustFieldAmount(fields.length);
forEditorField(
fields,
(field: EditorField, [name, fieldContent]: [string, string]): void =>
field.initialize(name, color, fieldContent)
);
if (!getCurrentField()) {
// when initial focus of the window is not on editor (e.g. browser)
fieldFocused.set(false);
}
}
export function setBackgrounds(cols: ("dupe" | "")[]): void {
forEditorField(cols, (field: EditorField, value: "dupe" | "") =>
field.editingArea.classList.toggle("dupe", value === "dupe")
);
document
.getElementById("dupes")!
.classList.toggle("d-none", !cols.includes("dupe"));
}
export function setClozeHint(hint: string): void {
const clozeHint = document.getElementById("cloze-hint")!;
clozeHint.innerHTML = hint;
clozeHint.classList.toggle("d-none", hint.length === 0);
}
export function setFonts(fonts: [string, number, boolean][]): void {
forEditorField(
fonts,
(
field: EditorField,
[fontFamily, fontSize, isRtl]: [string, number, boolean]
) => {
field.setBaseStyling(fontFamily, `${fontSize}px`, isRtl ? "rtl" : "ltr");
}
);
}
export function setColorButtons([textColor, highlightColor]: [string, string]): void {
$editorToolbar.then((editorToolbar) =>
(editorToolbar as any).$set({ textColor, highlightColor })
);
}
export function setSticky(stickies: boolean[]): void {
forEditorField(stickies, (field: EditorField, isSticky: boolean) => {
field.labelContainer.activateSticky(isSticky);
});
}
export function setFormat(cmd: string, arg?: string, nosave = false): void {
document.execCommand(cmd, false, arg);
if (!nosave) {
saveField(getCurrentField() as EditingArea, "key");
updateActiveButtons(new Event(cmd));
}
}
export function setTags(tags: string[]): void {
$tagEditor.then((tagEditor: TagEditor): void => tagEditor.resetTags(tags));
}
export const i18n = setupI18n({
modules: [
ModuleName.EDITING,
@ -207,5 +59,54 @@ export const i18n = setupI18n({
],
});
export const $editorToolbar: Promise<EditorToolbar> = initToolbar(i18n);
export const $tagEditor: Promise<TagEditor> = initTagEditor(i18n);
import OldEditorAdapter from "./OldEditorAdapter.svelte";
import { nightModeKey } from "../components/context-keys";
import "./editor-base.css";
import "./bootstrap.css";
import "./legacy.css";
function setupNoteEditor(i18n: Promise<void>): Promise<OldEditorAdapter> {
let editorResolve: (value: OldEditorAdapter) => void;
const editorPromise = new Promise<OldEditorAdapter>((resolve) => {
editorResolve = resolve;
});
const context = new Map<symbol, unknown>();
context.set(
nightModeKey,
document.documentElement.classList.contains("night-mode")
);
i18n.then(() => {
const noteEditor = new OldEditorAdapter({
target: document.body,
props: {
class: "h-100",
},
context,
} as any);
Object.assign(globalThis, {
setFields: noteEditor.setFields,
setFonts: noteEditor.setFonts,
focusField: noteEditor.focusField,
setColorButtons: noteEditor.setColorButtons,
setTags: noteEditor.setTags,
setSticky: noteEditor.setSticky,
setBackgrounds: noteEditor.setBackgrounds,
setClozeHint: noteEditor.setClozeHint,
saveNow: noteEditor.saveFieldNow,
activateStickyShortcuts: noteEditor.activateStickyShortcuts,
focusIfField: noteEditor.focusIfField,
setNoteId: noteEditor.setNoteId,
});
editorResolve(noteEditor);
});
return editorPromise;
}
export const noteEditorPromise = setupNoteEditor(i18n);

View File

@ -1,84 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
import { nodeIsElement } from "../lib/dom";
import { updateActiveButtons } from "./toolbar";
import { EditingArea } from "./editing-area";
import { triggerChangeTimer } from "./saving";
import { registerShortcut } from "../lib/shortcuts";
export function onInput(event: Event): void {
// make sure IME changes get saved
triggerChangeTimer(event.currentTarget as EditingArea);
updateActiveButtons(event);
}
export function onKey(evt: KeyboardEvent): void {
const currentField = evt.currentTarget as EditingArea;
// esc clears focus, allowing dialog to close
if (evt.code === "Escape") {
return currentField.blur();
}
// prefer <br> instead of <div></div>
if (evt.code === "Enter") {
return currentField.onEnter(evt);
}
// // fix Ctrl+right/left handling in RTL fields
if (currentField.isRightToLeft()) {
const selection = currentField.getSelection();
const granularity = evt.ctrlKey ? "word" : "character";
const alter = evt.shiftKey ? "extend" : "move";
switch (evt.code) {
case "ArrowRight":
selection.modify(alter, "right", granularity);
evt.preventDefault();
return;
case "ArrowLeft":
selection.modify(alter, "left", granularity);
evt.preventDefault();
return;
}
}
triggerChangeTimer(currentField);
}
function updateFocus(evt: FocusEvent) {
const newFocusTarget = evt.target;
if (newFocusTarget instanceof EditingArea) {
newFocusTarget.caretToEnd();
updateActiveButtons(evt);
}
}
registerShortcut(
() => document.addEventListener("focusin", updateFocus, { once: true }),
"Shift?+Tab"
);
export function onKeyUp(evt: KeyboardEvent): void {
const currentField = evt.currentTarget as EditingArea;
// Avoid div element on remove
if (evt.code === "Enter" || evt.code === "Backspace") {
const anchor = currentField.getSelection().anchorNode as Node;
if (
nodeIsElement(anchor) &&
anchor.tagName === "DIV" &&
!(anchor instanceof EditingArea) &&
anchor.childElementCount === 1 &&
anchor.children[0].tagName === "BR"
) {
anchor.replaceWith(anchor.children[0]);
}
}
}

View File

@ -1,130 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { EditorField } from "./editor-field";
import * as tr from "../lib/ftl";
import { registerShortcut } from "../lib/shortcuts";
import { bridgeCommand } from "./lib";
import { appendInParentheses } from "./helpers";
import { saveField } from "./saving";
import { getCurrentField, forEditorField, i18n } from ".";
import pinIcon from "bootstrap-icons/icons/pin-angle.svg";
function toggleStickyCurrentField(): void {
const currentField = getCurrentField();
if (currentField) {
const labelContainer = (currentField.parentElement as EditorField)
.labelContainer;
labelContainer.toggleSticky();
}
}
function toggleStickyAll(): void {
bridgeCommand("toggleStickyAll", (values: boolean[]) =>
forEditorField(values, (field, value) => field.labelContainer.setSticky(value))
);
}
export function activateStickyShortcuts(): void {
registerShortcut(toggleStickyCurrentField, "F9");
registerShortcut(toggleStickyAll, "Shift+F9");
}
export class LabelContainer extends HTMLDivElement {
label: HTMLSpanElement;
fieldState: HTMLSpanElement;
sticky: HTMLSpanElement;
constructor() {
super();
this.className = "fname d-flex justify-content-between";
this.label = document.createElement("span");
this.label.className = "fieldname";
this.appendChild(this.label);
this.fieldState = document.createElement("span");
this.fieldState.className = "field-state d-flex justify-content-between";
this.appendChild(this.fieldState);
this.sticky = document.createElement("span");
this.sticky.className = "icon pin-icon";
this.sticky.innerHTML = pinIcon;
this.sticky.hidden = true;
i18n.then(() => {
this.sticky.title = appendInParentheses(tr.editingToggleSticky(), "F9");
});
this.fieldState.appendChild(this.sticky);
this.setSticky = this.setSticky.bind(this);
this.hoverIcon = this.hoverIcon.bind(this);
this.removeHoverIcon = this.removeHoverIcon.bind(this);
this.toggleSticky = this.toggleSticky.bind(this);
this.toggleStickyEvent = this.toggleStickyEvent.bind(this);
this.keepFocus = this.keepFocus.bind(this);
this.stopPropagation = this.stopPropagation.bind(this);
}
keepFocus(event: Event): void {
event.preventDefault();
}
stopPropagation(event: Event): void {
this.keepFocus(event);
event.stopPropagation();
}
connectedCallback(): void {
this.addEventListener("mousedown", this.keepFocus);
this.sticky.addEventListener("mousedown", this.stopPropagation);
this.sticky.addEventListener("click", this.toggleStickyEvent);
this.sticky.addEventListener("mouseenter", this.hoverIcon);
this.sticky.addEventListener("mouseleave", this.removeHoverIcon);
}
disconnectedCallback(): void {
this.removeEventListener("mousedown", this.keepFocus);
this.sticky.removeEventListener("mousedown", this.stopPropagation);
this.sticky.removeEventListener("click", this.toggleStickyEvent);
this.sticky.removeEventListener("mouseenter", this.hoverIcon);
this.sticky.removeEventListener("mouseleave", this.removeHoverIcon);
}
initialize(labelName: string): void {
this.label.innerText = labelName;
}
activateSticky(initialState: boolean): void {
this.setSticky(initialState);
this.sticky.hidden = false;
}
setSticky(state: boolean): void {
this.sticky.classList.toggle("is-inactive", !state);
}
hoverIcon(): void {
this.sticky.classList.add("icon--hover");
}
removeHoverIcon(): void {
this.sticky.classList.remove("icon--hover");
}
toggleSticky(): void {
saveField((this.parentElement as EditorField).editingArea, "key");
bridgeCommand(`toggleSticky:${this.getAttribute("ord")}`, this.setSticky);
this.removeHoverIcon();
}
toggleStickyEvent(event: Event): void {
event.stopPropagation();
this.toggleSticky();
}
}
customElements.define("anki-label-container", LabelContainer, { extends: "div" });

View File

@ -1,12 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
declare global {
interface Window {
bridgeCommand<T>(command: string, callback?: (value: T) => void): void;
}
}
export function bridgeCommand<T>(command: string, callback?: (value: T) => void): void {
window.bridgeCommand<T>(command, callback);
}

View File

@ -4,8 +4,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import { onMount, createEventDispatcher } from "svelte";
import { ChangeTimer } from "./change-timer";
import { CodeMirror, latex, baseOptions } from "./codeMirror";
import { ChangeTimer } from "../change-timer";
import { CodeMirror, latex, baseOptions } from "../code-mirror";
export let initialValue: string;
@ -19,14 +19,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const dispatch = createEventDispatcher();
function onInput() {
changeTimer.schedule(
() => dispatch("update", { mathjax: codeMirror.getValue() }),
400
);
dispatch("update", { mathjax: codeMirror.getValue() });
/* changeTimer.schedule( */
/* () => dispatch("update", { mathjax: codeMirror.getValue() }), */
/* 400 */
/* ); */
}
function onBlur() {
changeTimer.fireImmediately();
dispatch("codemirrorblur");
}
function openCodemirror(textarea: HTMLTextAreaElement): void {

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="typescript">
import ButtonGroup from "../components/ButtonGroup.svelte";
import ButtonGroupItem from "../components/ButtonGroupItem.svelte";
import IconButton from "../components/IconButton.svelte";
import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
import IconButton from "../../components/IconButton.svelte";
import * as tr from "../lib/ftl";
import * as tr from "../../lib/ftl";
import { inlineIcon, blockIcon } from "./icons";
export let activeImage: HTMLImageElement;
export let isRtl: boolean;
$: mathjaxElement = activeImage.parentElement!;
</script>
<ButtonGroup size={1.6} wrap={false}>
{#if isRtl}
<!-- fixme -->
{/if}
<ButtonGroupItem>
<IconButton
tooltip={tr.editingMathjaxInline()}

View File

@ -0,0 +1,155 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import WithDropdown from "../../components/WithDropdown.svelte";
import ButtonToolbar from "../../components/ButtonToolbar.svelte";
import DropdownMenu from "../../components/DropdownMenu.svelte";
import Item from "../../components/Item.svelte";
import HandleSelection from "../HandleSelection.svelte";
import HandleBackground from "../HandleBackground.svelte";
import HandleControl from "../HandleControl.svelte";
import InlineBlock from "./InlineBlock.svelte";
import Editor from "./Editor.svelte";
import { onMount, tick } from "svelte";
import { getRichTextInput } from "../RichTextInput.svelte";
import { noop } from "../../lib/functional";
const { container, api } = getRichTextInput();
let activeImage: HTMLImageElement | null = null;
let allow: () => void;
function showHandle(image: HTMLImageElement): void {
allow = api.preventResubscription();
activeImage = image;
}
async function maybeShowHandle(event: Event): Promise<void> {
await resetHandle();
const target = event.target;
if (target instanceof HTMLImageElement && target.dataset.anki === "mathjax") {
showHandle(target);
}
}
async function showAutofocusHandle({
detail,
}: CustomEvent<HTMLImageElement>): Promise<void> {
await resetHandle();
showHandle(detail);
}
async function resetHandle(): Promise<void> {
if (activeImage) {
allow();
activeImage = null;
await tick();
}
}
onMount(() => {
container.addEventListener("click", maybeShowHandle);
container.addEventListener("focusmathjax" as any, showAutofocusHandle);
container.addEventListener("key", resetHandle);
container.addEventListener("paste", resetHandle);
return () => {
container.removeEventListener("click", maybeShowHandle);
container.removeEventListener("focusmathjax" as any, showAutofocusHandle);
container.removeEventListener("key", resetHandle);
container.removeEventListener("paste", resetHandle);
};
});
let dropdownApi: any;
async function onImageResize(): Promise<void> {
if (activeImage) {
errorMessage = activeImage.title;
await updateSelection();
dropdownApi.update();
}
}
let clearResize = noop;
function handleImageResizing(activeImage: HTMLImageElement | null) {
if (activeImage) {
activeImage.addEventListener("resize", onImageResize);
const lastImage = activeImage;
clearResize = () => lastImage.removeEventListener("resize", onImageResize);
} else {
clearResize();
}
}
$: handleImageResizing(activeImage);
const resizeObserver = new ResizeObserver(async () => {
if (activeImage) {
await updateSelection();
dropdownApi.update();
}
});
resizeObserver.observe(container);
let updateSelection: () => Promise<void>;
let errorMessage: string;
function getComponent(image: HTMLImageElement): HTMLElement {
return image.closest("anki-mathjax")! as HTMLElement;
}
function onEditorUpdate(event: CustomEvent): void {
/* this updates the image in Mathjax.svelte */
getComponent(activeImage!).dataset.mathjax = event.detail.mathjax;
}
</script>
<WithDropdown
drop="down"
autoOpen={true}
autoClose={false}
distance={4}
let:createDropdown
>
{#if activeImage}
<HandleSelection
image={activeImage}
{container}
bind:updateSelection
on:mount={(event) => (dropdownApi = createDropdown(event.detail.selection))}
>
<HandleBackground tooltip={errorMessage} />
<HandleControl offsetX={1} offsetY={1} />
</HandleSelection>
<DropdownMenu>
<Editor
initialValue={getComponent(activeImage).dataset.mathjax ?? ""}
on:update={onEditorUpdate}
on:codemirrorblur={resetHandle}
/>
<div class="margin-x">
<ButtonToolbar>
<Item>
<InlineBlock {activeImage} on:click={updateSelection} />
</Item>
</ButtonToolbar>
</div>
</DropdownMenu>
{/if}
</WithDropdown>
<style lang="scss">
.margin-x {
margin: 0 0.125rem;
}
</style>

View File

@ -0,0 +1,5 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export { default as inlineIcon } from "@mdi/svg/svg/format-wrap-square.svg";
export { default as blockIcon } from "@mdi/svg/svg/format-wrap-top-bottom.svg";

View File

@ -0,0 +1,4 @@
// 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";

View File

@ -1,12 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let currentNoteId: number | null = null;
export function setNoteId(id: number): void {
currentNoteId = id;
}
export function getNoteId(): number | null {
return currentNoteId;
}

View File

@ -1,39 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { EditingArea } from "./editing-area";
import { ChangeTimer } from "./change-timer";
import { getCurrentField } from "./helpers";
import { bridgeCommand } from "./lib";
import { getNoteId } from "./note-id";
const saveFieldTimer = new ChangeTimer();
export function triggerChangeTimer(currentField: EditingArea): void {
saveFieldTimer.schedule(() => saveField(currentField, "key"), 600);
}
export function saveField(currentField: EditingArea, type: "blur" | "key"): void {
saveFieldTimer.clear();
const command = `${type}:${currentField.ord}:${getNoteId()}:${
currentField.fieldHTML
}`;
bridgeCommand(command);
}
export function saveNow(keepFocus: boolean): void {
const currentField = getCurrentField();
if (!currentField) {
return;
}
if (keepFocus) {
saveFieldTimer.fireImmediately();
} else {
// triggers onBlur, which saves
saveFieldTimer.clear();
currentField.blur();
}
}

View File

@ -1,38 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
@typescript-eslint/no-explicit-any: "off",
*/
import { nightModeKey } from "../components/context-keys";
import TagEditor from "./TagEditor.svelte";
import "./bootstrap.css";
export function initTagEditor(i18n: Promise<void>): Promise<TagEditor> {
let tagEditorResolve: (value: TagEditor) => void;
const tagEditorPromise = new Promise<TagEditor>((resolve) => {
tagEditorResolve = resolve;
});
document.addEventListener("DOMContentLoaded", () =>
i18n.then(() => {
const target = document.body;
const anchor = document.getElementById("tag-editor-anchor")!;
const context = new Map();
context.set(
nightModeKey,
document.documentElement.classList.contains("night-mode")
);
tagEditorResolve(new TagEditor({ target, anchor, context } as any));
})
);
return tagEditorPromise;
}
export {} from "./TagEditor.svelte";

View File

@ -1,49 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
@typescript-eslint/no-explicit-any: "off",
*/
import { nightModeKey } from "../components/context-keys";
import { fieldFocusedKey, inCodableKey } from "./context-keys";
import { writable } from "svelte/store";
import EditorToolbar from "./EditorToolbar.svelte";
import "./bootstrap.css";
export const fieldFocused = writable(false);
export const inCodable = writable(false);
export function initToolbar(i18n: Promise<void>): Promise<EditorToolbar> {
let toolbarResolve: (value: EditorToolbar) => void;
const toolbarPromise = new Promise<EditorToolbar>((resolve) => {
toolbarResolve = resolve;
});
document.addEventListener("DOMContentLoaded", () =>
i18n.then(() => {
const target = document.body;
const anchor = document.getElementById("fields")!;
const context = new Map();
context.set(fieldFocusedKey, fieldFocused);
context.set(inCodableKey, inCodable);
context.set(
nightModeKey,
document.documentElement.classList.contains("night-mode")
);
toolbarResolve(new EditorToolbar({ target, anchor, context } as any));
})
);
return toolbarPromise;
}
export {
updateActiveButtons,
clearActiveButtons,
editorToolbar,
} from "./EditorToolbar.svelte";

View File

@ -1,6 +1,6 @@
{
"extends": "../tsconfig.json",
"include": ["*"],
"include": ["*", "image-overlay/*", "mathjax-overlay/*"],
"references": [
{ "path": "../components" },
{ "path": "../lib" },

View File

@ -1,31 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
import { wrapInternal } from "../lib/wrap";
import { getCurrentField } from ".";
export function wrap(front: string, back: string): void {
const editingArea = getCurrentField();
if (editingArea) {
wrapInternal(editingArea.editableContainer.shadowRoot!, front, back, false);
}
}
export function wrapCurrent(front: string, back: string): void {
const currentField = getCurrentField()!;
currentField.surroundSelection(front, back);
}
/* currently unused */
export function wrapIntoText(front: string, back: string): void {
const editingArea = getCurrentField();
if (editingArea) {
wrapInternal(editingArea.editableContainer.shadowRoot!, front, back, false);
}
}

6
ts/lib/context-keys.ts Normal file
View File

@ -0,0 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const fontFamilyKey = Symbol("fontFamily");
export const fontSizeKey = Symbol("fontSize");
export const directionKey = Symbol("direction");

View File

@ -50,8 +50,30 @@ export function elementIsBlock(element: Element): boolean {
return BLOCK_TAGS.includes(element.tagName);
}
export function nodeContainsInlineContent(node: Node): boolean {
for (const child of node.childNodes) {
if (
(nodeIsElement(child) && elementIsBlock(child)) ||
!nodeContainsInlineContent(child)
) {
return false;
}
}
// empty node is trivially inline
return true;
}
export function fragmentToString(fragment: DocumentFragment): string {
const fragmentDiv = document.createElement("div");
fragmentDiv.appendChild(fragment);
const html = fragmentDiv.innerHTML;
return html;
}
export function caretToEnd(node: Node): void {
const range = document.createRange();
const range = new Range();
range.selectNodeContents(node);
range.collapse(false);
const selection = (node.getRootNode() as Document | ShadowRoot).getSelection()!;
@ -61,8 +83,8 @@ export function caretToEnd(node: Node): void {
const getAnchorParent =
<T extends Element>(predicate: (element: Element) => element is T) =>
(currentField: DocumentOrShadowRoot): T | null => {
const anchor = currentField.getSelection()?.anchorNode;
(root: DocumentOrShadowRoot): T | null => {
const anchor = root.getSelection()?.anchorNode;
if (!anchor) {
return null;

6
ts/lib/functional.ts Normal file
View File

@ -0,0 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const noop: () => void = () => {
/* noop */
};

13
ts/lib/promise.ts Normal file
View File

@ -0,0 +1,13 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
export function promiseWithResolver<T>(): [Promise<T>, (value: T) => void] {
let resolve: (object: T) => void;
const promise = new Promise<T>((res) => (resolve = res));
return [promise, resolve!];
}

View File

@ -4,4 +4,8 @@ declare global {
interface DocumentOrShadowRoot {
getSelection(): Selection | null;
}
interface Node {
getRootNode(options?: GetRootNodeOptions): DocumentOrShadowRoot;
}
}

View File

@ -116,6 +116,7 @@ const GENERAL_KEY = 0;
const NUMPAD_KEY = 3;
function innerShortcut(
target: EventTarget | Document,
lastEvent: KeyboardEvent,
callback: (event: KeyboardEvent) => void,
...checks: ((event: KeyboardEvent) => boolean)[]
@ -128,7 +129,7 @@ function innerShortcut(
const [nextCheck, ...restChecks] = checks;
const handler = (event: KeyboardEvent): void => {
if (nextCheck(event)) {
innerShortcut(event, callback, ...restChecks);
innerShortcut(target, event, callback, ...restChecks);
clearTimeout(interval);
} else if (
event.location === GENERAL_KEY ||
@ -145,19 +146,20 @@ function innerShortcut(
export function registerShortcut(
callback: (event: KeyboardEvent) => void,
keyCombinationString: string
keyCombinationString: string,
target: EventTarget | Document = document
): () => void {
const [check, ...restChecks] =
splitKeyCombinationString(keyCombinationString).map(keyCombinationToCheck);
const handler = (event: KeyboardEvent): void => {
if (check(event)) {
innerShortcut(event, callback, ...restChecks);
innerShortcut(target, event, callback, ...restChecks);
}
};
document.addEventListener("keydown", handler);
return (): void => document.removeEventListener("keydown", handler);
target.addEventListener("keydown", handler as EventListener);
return (): void => target.removeEventListener("keydown", handler as EventListener);
}
registerPackage("anki/shortcuts", {

View File

@ -19,7 +19,7 @@ function moveCursorPastPostfix(selection: Selection, postfix: string): void {
}
export function wrapInternal(
root: Document | ShadowRoot,
root: DocumentOrShadowRoot,
front: string,
back: string,
plainText: boolean

View File

@ -7,7 +7,7 @@
"path": "node_modules/@fluent/bundle",
"licenseFile": "node_modules/@fluent/bundle/README.md"
},
"@mdi/svg@6.1.95": {
"@mdi/svg@6.2.95": {
"licenses": "Apache-2.0",
"repository": "https://github.com/Templarian/MaterialDesign-SVG",
"publisher": "Austin Andrews",

View File

@ -0,0 +1,31 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { setContext, getContext, hasContext } from "svelte";
type ContextProperty<T> = [
(value: T) => T,
// this typing is a lie insofar that calling get
// outside of the component's context will return undefined
() => T,
() => boolean
];
function contextProperty<T>(key: symbol): ContextProperty<T> {
function set(context: T): T {
setContext(key, context);
return context;
}
function get(): T {
return getContext(key);
}
function has(): boolean {
return hasContext(key);
}
return [set, get, has];
}
export default contextProperty;

Some files were not shown because too many files have changed in this diff Show More