From 2637a4955b7f372ea04127ef058b306ffae8fbee Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 8 Feb 2021 15:44:56 +0100 Subject: [PATCH 01/10] Export current field for editor --- ts/editor/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 46cf52524..6bcd68579 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -18,6 +18,14 @@ declare global { } } +export function getCurrentField(): EditingArea | null { + return currentField; +} + +export function getCurrentNoteId(): number | null { + return currentNoteId; +} + export function setFGButton(col: string): void { document.getElementById("forecolor").style.backgroundColor = col; } From ef14000afdd6e7800272de6d9b63eb19abfef651 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 8 Feb 2021 17:00:27 +0100 Subject: [PATCH 02/10] Avoid making currentField a global --- ts/editor/editor.scss | 3 +- ts/editor/index.ts | 112 ++++++++++++++++++++---------------------- 2 files changed, 55 insertions(+), 60 deletions(-) diff --git a/ts/editor/editor.scss b/ts/editor/editor.scss index 32bea7b6b..9caf8a996 100644 --- a/ts/editor/editor.scss +++ b/ts/editor/editor.scss @@ -10,7 +10,8 @@ html { flex-direction: column; margin: 5px; - & > *, & > * > * { + & > *, + & > * > * { margin: 1px 0; &:first-child { diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 6bcd68579..304660098 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -5,7 +5,6 @@ import { filterHTML } from "./filterHtml"; import { nodeIsElement, nodeIsInline } from "./helpers"; import { bridgeCommand } from "./lib"; -let currentField: EditingArea | null = null; let changeTimer: number | null = null; let currentNoteId: number | null = null; @@ -19,18 +18,18 @@ declare global { } export function getCurrentField(): EditingArea | null { - return currentField; -} - -export function getCurrentNoteId(): number | null { - return currentNoteId; + return document.activeElement instanceof EditingArea + ? document.activeElement + : null; } export function setFGButton(col: string): void { - document.getElementById("forecolor").style.backgroundColor = col; + document.getElementById("forecolor")!.style.backgroundColor = col; } export function saveNow(keepFocus: boolean): void { + const currentField = getCurrentField(); + if (!currentField) { return; } @@ -38,22 +37,24 @@ export function saveNow(keepFocus: boolean): void { clearChangeTimer(); if (keepFocus) { - saveField("key"); + saveField(currentField, "key"); } else { // triggers onBlur, which saves currentField.blurEditable(); } } -function triggerKeyTimer(): void { +function triggerKeyTimer(currentField: EditingArea): void { clearChangeTimer(); changeTimer = setTimeout(function () { updateButtonState(); - saveField("key"); + saveField(currentField, "key"); }, 600); } function onKey(evt: KeyboardEvent): void { + const currentField = evt.currentTarget as EditingArea; + // esc clears focus, allowing dialog to close if (evt.code === "Escape") { currentField.blurEditable(); @@ -61,7 +62,7 @@ function onKey(evt: KeyboardEvent): void { } // prefer
instead of
- if (evt.code === "Enter" && !inListItem()) { + if (evt.code === "Enter" && !inListItem(currentField)) { evt.preventDefault(); document.execCommand("insertLineBreak"); } @@ -84,13 +85,15 @@ function onKey(evt: KeyboardEvent): void { } } - triggerKeyTimer(); + triggerKeyTimer(currentField); } 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; + const anchor = currentField.getSelection().anchorNode as Node; if ( nodeIsElement(anchor) && @@ -104,8 +107,8 @@ function onKeyUp(evt: KeyboardEvent): void { } } -function inListItem(): boolean { - const anchor = currentField.getSelection().anchorNode; +function inListItem(currentField: EditingArea): boolean { + const anchor = currentField.getSelection()!.anchorNode!; let inList = false; let n = nodeIsElement(anchor) ? anchor : anchor.parentElement; @@ -117,9 +120,9 @@ function inListItem(): boolean { return inList; } -function onInput(): void { +function onInput(event: Event): void { // make sure IME changes get saved - triggerKeyTimer(); + triggerKeyTimer(event.currentTarget as EditingArea); } function updateButtonState(): void { @@ -141,7 +144,7 @@ export function toggleEditorButton(buttonid: string): void { export function setFormat(cmd: string, arg?: any, nosave: boolean = false): void { document.execCommand(cmd, false, arg); if (!nosave) { - saveField("key"); + saveField(getCurrentField() as EditingArea, "key"); updateButtonState(); } } @@ -154,17 +157,12 @@ function clearChangeTimer(): void { } function onFocus(evt: FocusEvent): void { - const elem = evt.currentTarget as EditingArea; - if (currentField === elem) { - // anki window refocused; current element unchanged - return; - } - elem.focusEditable(); - currentField = elem; + const currentField = evt.currentTarget as EditingArea; + currentField.focusEditable(); bridgeCommand(`focus:${currentField.ord}`); enableButtons(); // do this twice so that there's no flicker on newer versions - caretToEnd(); + caretToEnd(currentField); // scroll if bottom of element off the screen function pos(elem: HTMLElement): number { let cur = 0; @@ -175,12 +173,12 @@ function onFocus(evt: FocusEvent): void { return cur; } - const y = pos(elem); + const y = pos(currentField); if ( - window.pageYOffset + window.innerHeight < y + elem.offsetHeight || + window.pageYOffset + window.innerHeight < y + currentField.offsetHeight || window.pageYOffset > y ) { - window.scroll(0, y + elem.offsetHeight - window.innerHeight); + window.scroll(0, y + currentField.offsetHeight - window.innerHeight); } } @@ -198,21 +196,18 @@ export function focusIfField(x: number, y: number): boolean { let elem = elements[i] as EditingArea; if (elem instanceof EditingArea) { elem.focusEditable(); - // the focus event may not fire if the window is not active, so make sure - // the current field is set - currentField = elem; return true; } } return false; } -function onPaste(event: ClipboardEvent): void { +function onPaste(evt: ClipboardEvent): void { bridgeCommand("paste"); - event.preventDefault(); + evt.preventDefault(); } -function caretToEnd(): void { +function caretToEnd(currentField: EditingArea): void { const range = document.createRange(); range.selectNodeContents(currentField.editable); range.collapse(false); @@ -221,17 +216,14 @@ function caretToEnd(): void { selection.addRange(range); } -function onBlur(): void { - if (!currentField) { - return; - } +function onBlur(evt: FocusEvent): void { + const currentField = evt.currentTarget as EditingArea; if (document.activeElement === currentField) { // other widget or window focused; current field unchanged - saveField("key"); + saveField(currentField, "key"); } else { - saveField("blur"); - currentField = null; + saveField(currentField, "blur"); disableButtons(); } } @@ -251,20 +243,15 @@ function containsInlineContent(field: Element): boolean { return true; } -function saveField(type: "blur" | "key"): void { +function saveField(currentField: EditingArea, type: "blur" | "key"): void { clearChangeTimer(); - if (!currentField) { - // no field has been focused yet - return; - } - bridgeCommand( - `${type}:${currentField.ord}:${currentNoteId}:${currentField.fieldHTML}` + `${type}:${currentField.ord}:${getCurrentNoteId()}:${currentField.fieldHTML}` ); } function wrappedExceptForWhitespace(text: string, front: string, back: string): string { - const match = text.match(/^(\s*)([^]*?)(\s*)$/); + const match = text.match(/^(\s*)([^]*?)(\s*)$/)!; return match[1] + front + match[2] + back + match[3]; } @@ -303,11 +290,13 @@ export function wrapIntoText(front: string, back: string): void { } function wrapInternal(front: string, back: string, plainText: boolean): void { + const currentField = getCurrentField()!; const s = currentField.getSelection(); let r = s.getRangeAt(0); const content = r.cloneContents(); const span = document.createElement("span"); span.appendChild(content); + if (plainText) { const new_ = wrappedExceptForWhitespace(span.innerText, front, back); setFormat("inserttext", new_); @@ -315,6 +304,7 @@ function wrapInternal(front: string, back: string, plainText: boolean): void { const new_ = wrappedExceptForWhitespace(span.innerHTML, front, back); setFormat("inserthtml", new_); } + if (!span.innerHTML) { // run with an empty selection; move cursor back past postfix r = s.getRangeAt(0); @@ -364,14 +354,14 @@ class EditingArea extends HTMLDivElement { const rootStyle = document.createElement("link"); rootStyle.setAttribute("rel", "stylesheet"); rootStyle.setAttribute("href", "./_anki/css/editable.css"); - this.shadowRoot.appendChild(rootStyle); + this.shadowRoot!.appendChild(rootStyle); this.baseStyle = document.createElement("style"); this.baseStyle.setAttribute("rel", "stylesheet"); - this.shadowRoot.appendChild(this.baseStyle); + this.shadowRoot!.appendChild(this.baseStyle); this.editable = document.createElement("anki-editable") as Editable; - this.shadowRoot.appendChild(this.editable); + this.shadowRoot!.appendChild(this.editable); } get ord(): number { @@ -435,7 +425,7 @@ class EditingArea extends HTMLDivElement { } getSelection(): Selection { - return this.shadowRoot.getSelection(); + return this.shadowRoot!.getSelection()!; } focusEditable(): void { @@ -498,7 +488,7 @@ class EditorField extends HTMLDivElement { customElements.define("anki-editor-field", EditorField, { extends: "div" }); function adjustFieldAmount(amount: number): void { - const fieldsContainer = document.getElementById("fields"); + const fieldsContainer = document.getElementById("fields")!; while (fieldsContainer.childElementCount < amount) { const newField = document.createElement("div", { @@ -509,12 +499,12 @@ function adjustFieldAmount(amount: number): void { } while (fieldsContainer.childElementCount > amount) { - fieldsContainer.removeChild(fieldsContainer.lastElementChild); + fieldsContainer.removeChild(fieldsContainer.lastElementChild as Node); } } export function getEditorField(n: number): EditorField | null { - const fields = document.getElementById("fields").children; + const fields = document.getElementById("fields")!.children; return (fields[n] as EditorField) ?? null; } @@ -522,7 +512,7 @@ export function forEditorField( values: T[], func: (field: EditorField, value: T) => void ): void { - const fields = document.getElementById("fields").children; + const fields = document.getElementById("fields")!.children; for (let i = 0; i < fields.length; i++) { const field = fields[i] as EditorField; func(field, values[i]); @@ -549,7 +539,7 @@ export function setBackgrounds(cols: ("dupe" | "")[]) { field.editingArea.classList.toggle("dupe", value === "dupe") ); document - .querySelector("#dupes") + .getElementById("dupes")! .classList.toggle("is-inactive", !cols.includes("dupe")); } @@ -563,6 +553,10 @@ export function setNoteId(id: number): void { currentNoteId = id; } +export function getCurrentNoteId(): number | null { + return currentNoteId; +} + export let pasteHTML = function ( html: string, internal: boolean, From 934a9bd24b6b3ebc7abc7145f51fe12886ea7cab Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 8 Feb 2021 19:45:42 +0100 Subject: [PATCH 03/10] Split up index.ts into several more files This is to provide minimal closures for the mutable file-local variables: - changeTimer - previousActiveElement - currentNoteId This makes it clear, that they should not be used, but rather the functions which wrap them in an API --- ts/editor/changeTimer.ts | 47 ++++++ ts/editor/focusHandlers.ts | 60 +++++++ ts/editor/index.ts | 318 +++++++------------------------------ ts/editor/inputHandlers.ts | 76 +++++++++ ts/editor/noteId.ts | 9 ++ ts/editor/toolbar.ts | 46 ++++++ 6 files changed, 293 insertions(+), 263 deletions(-) create mode 100644 ts/editor/changeTimer.ts create mode 100644 ts/editor/focusHandlers.ts create mode 100644 ts/editor/inputHandlers.ts create mode 100644 ts/editor/noteId.ts create mode 100644 ts/editor/toolbar.ts diff --git a/ts/editor/changeTimer.ts b/ts/editor/changeTimer.ts new file mode 100644 index 000000000..0b6566c4c --- /dev/null +++ b/ts/editor/changeTimer.ts @@ -0,0 +1,47 @@ +import type { EditingArea } from "."; + +import { getCurrentField } from "."; +import { bridgeCommand } from "./lib"; +import { getNoteId } from "./noteId"; +import { updateButtonState } from "./toolbar"; + +let changeTimer: number | null = null; + +export function triggerChangeTimer(currentField: EditingArea): void { + clearChangeTimer(); + changeTimer = setTimeout(function () { + updateButtonState(); + saveField(currentField, "key"); + }, 600); +} + +function clearChangeTimer(): void { + if (changeTimer) { + clearTimeout(changeTimer); + changeTimer = null; + } +} + +export function saveField(currentField: EditingArea, type: "blur" | "key"): void { + clearChangeTimer(); + bridgeCommand( + `${type}:${currentField.ord}:${getNoteId()}:${currentField.fieldHTML}` + ); +} + +export function saveNow(keepFocus: boolean): void { + const currentField = getCurrentField(); + + if (!currentField) { + return; + } + + clearChangeTimer(); + + if (keepFocus) { + saveField(currentField, "key"); + } else { + // triggers onBlur, which saves + currentField.blurEditable(); + } +} diff --git a/ts/editor/focusHandlers.ts b/ts/editor/focusHandlers.ts new file mode 100644 index 000000000..6e655be5c --- /dev/null +++ b/ts/editor/focusHandlers.ts @@ -0,0 +1,60 @@ +import type { EditingArea } from "."; + +import { bridgeCommand } from "./lib"; +import { enableButtons, disableButtons } from "./toolbar"; +import { saveField } from "./changeTimer"; + +function isElementInViewport(element: Element): boolean { + const rect = element.getBoundingClientRect(); + + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); +} + +function caretToEnd(currentField: EditingArea): void { + const range = document.createRange(); + range.selectNodeContents(currentField.editable); + range.collapse(false); + const selection = currentField.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +// For distinguishing focus by refocusing window from deliberate focus +let previousActiveElement: EditingArea | null = null; + +export function onFocus(evt: FocusEvent): void { + const currentField = evt.currentTarget as EditingArea; + + if (currentField === previousActiveElement) { + return; + } + + currentField.focusEditable(); + bridgeCommand(`focus:${currentField.ord}`); + enableButtons(); + // do this twice so that there's no flicker on newer versions + caretToEnd(currentField); + // scroll if bottom of element off the screen + if (!isElementInViewport(currentField)) { + currentField.scrollIntoView(false /* alignToBottom */); + } +} + +export function onBlur(evt: FocusEvent): void { + const currentField = evt.currentTarget as EditingArea; + + if (currentField === previousActiveElement) { + // other widget or window focused; current field unchanged + saveField(currentField, "key"); + previousActiveElement = currentField; + } else { + saveField(currentField, "blur"); + disableButtons(); + previousActiveElement = null; + } +} diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 304660098..73295bfb7 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -2,11 +2,15 @@ * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ import { filterHTML } from "./filterHtml"; -import { nodeIsElement, nodeIsInline } from "./helpers"; +import { nodeIsInline } from "./helpers"; import { bridgeCommand } from "./lib"; +import { saveField } from "./changeTimer"; +import { updateButtonState, maybeDisableButtons } from "./toolbar"; +import { onInput, onKey, onKeyUp } from "./inputHandlers"; +import { onFocus, onBlur } from "./focusHandlers"; -let changeTimer: number | null = null; -let currentNoteId: number | null = null; +export { setNoteId, getNoteId } from "./noteId"; +export { preventButtonFocus, toggleEditorButton, setFGButton } from "./toolbar"; declare global { interface Selection { @@ -23,165 +27,6 @@ export function getCurrentField(): EditingArea | null { : null; } -export function setFGButton(col: string): void { - document.getElementById("forecolor")!.style.backgroundColor = col; -} - -export function saveNow(keepFocus: boolean): void { - const currentField = getCurrentField(); - - if (!currentField) { - return; - } - - clearChangeTimer(); - - if (keepFocus) { - saveField(currentField, "key"); - } else { - // triggers onBlur, which saves - currentField.blurEditable(); - } -} - -function triggerKeyTimer(currentField: EditingArea): void { - clearChangeTimer(); - changeTimer = setTimeout(function () { - updateButtonState(); - saveField(currentField, "key"); - }, 600); -} - -function onKey(evt: KeyboardEvent): void { - const currentField = evt.currentTarget as EditingArea; - - // esc clears focus, allowing dialog to close - if (evt.code === "Escape") { - currentField.blurEditable(); - return; - } - - // prefer
instead of
- if (evt.code === "Enter" && !inListItem(currentField)) { - evt.preventDefault(); - document.execCommand("insertLineBreak"); - } - - // // 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; - } - } - - triggerKeyTimer(currentField); -} - -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]); - } - } -} - -function inListItem(currentField: EditingArea): boolean { - const anchor = currentField.getSelection()!.anchorNode!; - - let inList = false; - let n = nodeIsElement(anchor) ? anchor : anchor.parentElement; - while (n) { - inList = inList || window.getComputedStyle(n).display == "list-item"; - n = n.parentElement; - } - - return inList; -} - -function onInput(event: Event): void { - // make sure IME changes get saved - triggerKeyTimer(event.currentTarget as EditingArea); -} - -function updateButtonState(): void { - const buts = ["bold", "italic", "underline", "superscript", "subscript"]; - for (const name of buts) { - const elem = document.querySelector(`#${name}`) as HTMLElement; - elem.classList.toggle("highlighted", document.queryCommandState(name)); - } - - // fixme: forecolor - // 'col': document.queryCommandValue("forecolor") -} - -export function toggleEditorButton(buttonid: string): void { - const button = $(buttonid)[0]; - button.classList.toggle("highlighted"); -} - -export function setFormat(cmd: string, arg?: any, nosave: boolean = false): void { - document.execCommand(cmd, false, arg); - if (!nosave) { - saveField(getCurrentField() as EditingArea, "key"); - updateButtonState(); - } -} - -function clearChangeTimer(): void { - if (changeTimer) { - clearTimeout(changeTimer); - changeTimer = null; - } -} - -function onFocus(evt: FocusEvent): void { - const currentField = evt.currentTarget as EditingArea; - currentField.focusEditable(); - bridgeCommand(`focus:${currentField.ord}`); - enableButtons(); - // do this twice so that there's no flicker on newer versions - caretToEnd(currentField); - // scroll if bottom of element off the screen - function pos(elem: HTMLElement): number { - let cur = 0; - do { - cur += elem.offsetTop; - elem = elem.offsetParent as HTMLElement; - } while (elem); - return cur; - } - - const y = pos(currentField); - if ( - window.pageYOffset + window.innerHeight < y + currentField.offsetHeight || - window.pageYOffset > y - ) { - window.scroll(0, y + currentField.offsetHeight - window.innerHeight); - } -} - export function focusField(n: number): void { const field = getEditorField(n); @@ -207,25 +52,9 @@ function onPaste(evt: ClipboardEvent): void { evt.preventDefault(); } -function caretToEnd(currentField: EditingArea): void { - const range = document.createRange(); - range.selectNodeContents(currentField.editable); - range.collapse(false); - const selection = currentField.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); -} - -function onBlur(evt: FocusEvent): void { - const currentField = evt.currentTarget as EditingArea; - - if (document.activeElement === currentField) { - // other widget or window focused; current field unchanged - saveField(currentField, "key"); - } else { - saveField(currentField, "blur"); - disableButtons(); - } +function onCutOrCopy(): boolean { + bridgeCommand("cutOrCopy"); + return true; } function containsInlineContent(field: Element): boolean { @@ -243,83 +72,6 @@ function containsInlineContent(field: Element): boolean { return true; } -function saveField(currentField: EditingArea, type: "blur" | "key"): void { - clearChangeTimer(); - bridgeCommand( - `${type}:${currentField.ord}:${getCurrentNoteId()}:${currentField.fieldHTML}` - ); -} - -function wrappedExceptForWhitespace(text: string, front: string, back: string): string { - const match = text.match(/^(\s*)([^]*?)(\s*)$/)!; - return match[1] + front + match[2] + back + match[3]; -} - -export function preventButtonFocus(): void { - for (const element of document.querySelectorAll("button.linkb")) { - element.addEventListener("mousedown", (evt: Event) => { - evt.preventDefault(); - }); - } -} - -function disableButtons(): void { - $("button.linkb:not(.perm)").prop("disabled", true); -} - -function enableButtons(): void { - $("button.linkb").prop("disabled", false); -} - -// disable the buttons if a field is not currently focused -function maybeDisableButtons(): void { - if (document.activeElement instanceof EditingArea) { - enableButtons(); - } else { - disableButtons(); - } -} - -export function wrap(front: string, back: string): void { - wrapInternal(front, back, false); -} - -/* currently unused */ -export function wrapIntoText(front: string, back: string): void { - wrapInternal(front, back, true); -} - -function wrapInternal(front: string, back: string, plainText: boolean): void { - const currentField = getCurrentField()!; - const s = currentField.getSelection(); - let r = s.getRangeAt(0); - const content = r.cloneContents(); - const span = document.createElement("span"); - span.appendChild(content); - - if (plainText) { - const new_ = wrappedExceptForWhitespace(span.innerText, front, back); - setFormat("inserttext", new_); - } else { - const new_ = wrappedExceptForWhitespace(span.innerHTML, front, back); - setFormat("inserthtml", new_); - } - - if (!span.innerHTML) { - // run with an empty selection; move cursor back past postfix - r = s.getRangeAt(0); - r.setStart(r.startContainer, r.startOffset - back.length); - r.collapse(true); - s.removeAllRanges(); - s.addRange(r); - } -} - -function onCutOrCopy(): boolean { - bridgeCommand("cutOrCopy"); - return true; -} - class Editable extends HTMLElement { set fieldHTML(content: string) { this.innerHTML = content; @@ -342,7 +94,7 @@ class Editable extends HTMLElement { customElements.define("anki-editable", Editable); -class EditingArea extends HTMLDivElement { +export class EditingArea extends HTMLDivElement { editable: Editable; baseStyle: HTMLStyleElement; @@ -549,12 +301,52 @@ export function setFonts(fonts: [string, number, boolean][]): void { }); } -export function setNoteId(id: number): void { - currentNoteId = id; +function wrappedExceptForWhitespace(text: string, front: string, back: string): string { + const match = text.match(/^(\s*)([^]*?)(\s*)$/)!; + return match[1] + front + match[2] + back + match[3]; } -export function getCurrentNoteId(): number | null { - return currentNoteId; +export function wrap(front: string, back: string): void { + wrapInternal(front, back, false); +} + +/* currently unused */ +export function wrapIntoText(front: string, back: string): void { + wrapInternal(front, back, true); +} + +export function setFormat(cmd: string, arg?: any, nosave: boolean = false): void { + document.execCommand(cmd, false, arg); + if (!nosave) { + saveField(getCurrentField() as EditingArea, "key"); + updateButtonState(); + } +} + +function wrapInternal(front: string, back: string, plainText: boolean): void { + const currentField = getCurrentField()!; + const s = currentField.getSelection(); + let r = s.getRangeAt(0); + const content = r.cloneContents(); + const span = document.createElement("span"); + span.appendChild(content); + + if (plainText) { + const new_ = wrappedExceptForWhitespace(span.innerText, front, back); + setFormat("inserttext", new_); + } else { + const new_ = wrappedExceptForWhitespace(span.innerHTML, front, back); + setFormat("inserthtml", new_); + } + + if (!span.innerHTML) { + // run with an empty selection; move cursor back past postfix + r = s.getRangeAt(0); + r.setStart(r.startContainer, r.startOffset - back.length); + r.collapse(true); + s.removeAllRanges(); + s.addRange(r); + } } export let pasteHTML = function ( diff --git a/ts/editor/inputHandlers.ts b/ts/editor/inputHandlers.ts new file mode 100644 index 000000000..d60c64165 --- /dev/null +++ b/ts/editor/inputHandlers.ts @@ -0,0 +1,76 @@ +import { EditingArea } from "."; +import { nodeIsElement } from "./helpers"; +import { triggerChangeTimer } from "./changeTimer"; + +function inListItem(currentField: EditingArea): boolean { + const anchor = currentField.getSelection()!.anchorNode!; + + let inList = false; + let n = nodeIsElement(anchor) ? anchor : anchor.parentElement; + while (n) { + inList = inList || window.getComputedStyle(n).display == "list-item"; + n = n.parentElement; + } + + return inList; +} + +export function onInput(event: Event): void { + // make sure IME changes get saved + triggerChangeTimer(event.currentTarget as EditingArea); +} + +export function onKey(evt: KeyboardEvent): void { + const currentField = evt.currentTarget as EditingArea; + + // esc clears focus, allowing dialog to close + if (evt.code === "Escape") { + currentField.blurEditable(); + return; + } + + // prefer
instead of
+ if (evt.code === "Enter" && !inListItem(currentField)) { + evt.preventDefault(); + document.execCommand("insertLineBreak"); + } + + // // 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); +} + +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]); + } + } +} diff --git a/ts/editor/noteId.ts b/ts/editor/noteId.ts new file mode 100644 index 000000000..e787a77d6 --- /dev/null +++ b/ts/editor/noteId.ts @@ -0,0 +1,9 @@ +let currentNoteId: number | null = null; + +export function setNoteId(id: number): void { + currentNoteId = id; +} + +export function getNoteId(): number | null { + return currentNoteId; +} diff --git a/ts/editor/toolbar.ts b/ts/editor/toolbar.ts new file mode 100644 index 000000000..f0410d78f --- /dev/null +++ b/ts/editor/toolbar.ts @@ -0,0 +1,46 @@ +import { EditingArea } from "."; + +export function updateButtonState(): void { + const buts = ["bold", "italic", "underline", "superscript", "subscript"]; + for (const name of buts) { + const elem = document.querySelector(`#${name}`) as HTMLElement; + elem.classList.toggle("highlighted", document.queryCommandState(name)); + } + + // fixme: forecolor + // 'col': document.queryCommandValue("forecolor") +} + +export function preventButtonFocus(): void { + for (const element of document.querySelectorAll("button.linkb")) { + element.addEventListener("mousedown", (evt: Event) => { + evt.preventDefault(); + }); + } +} + +export function disableButtons(): void { + $("button.linkb:not(.perm)").prop("disabled", true); +} + +export function enableButtons(): void { + $("button.linkb").prop("disabled", false); +} + +// disable the buttons if a field is not currently focused +export function maybeDisableButtons(): void { + if (document.activeElement instanceof EditingArea) { + enableButtons(); + } else { + disableButtons(); + } +} + +export function setFGButton(col: string): void { + document.getElementById("forecolor")!.style.backgroundColor = col; +} + +export function toggleEditorButton(buttonid: string): void { + const button = $(buttonid)[0]; + button.classList.toggle("highlighted"); +} From bc7a1d12cdc5116d5eb0118d81a9b7ac44984682 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 8 Feb 2021 20:28:02 +0100 Subject: [PATCH 04/10] Export saveNow --- ts/editor/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 73295bfb7..6eaad8ae3 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -11,6 +11,7 @@ import { onFocus, onBlur } from "./focusHandlers"; export { setNoteId, getNoteId } from "./noteId"; export { preventButtonFocus, toggleEditorButton, setFGButton } from "./toolbar"; +export { saveNow } from "./changeTimer"; declare global { interface Selection { From a19bc2d0124caa361db7a47486c89fbb813b6416 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 8 Feb 2021 20:49:33 +0100 Subject: [PATCH 05/10] updateButtonState on clicking editor field --- ts/editor/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 6eaad8ae3..81f42b5e6 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -138,6 +138,7 @@ export class EditingArea extends HTMLDivElement { this.addEventListener("paste", onPaste); this.addEventListener("copy", onCutOrCopy); this.addEventListener("oncut", onCutOrCopy); + this.addEventListener("click", updateButtonState); const baseStyleSheet = this.baseStyle.sheet as CSSStyleSheet; baseStyleSheet.insertRule("anki-editable {}", 0); @@ -152,6 +153,7 @@ export class EditingArea extends HTMLDivElement { this.removeEventListener("paste", onPaste); this.removeEventListener("copy", onCutOrCopy); this.removeEventListener("oncut", onCutOrCopy); + this.removeEventListener("click", updateButtonState); } initialize(color: string, content: string): void { From 61c4ef40de76f81a2675ba3bb67bb243ff4350d9 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 8 Feb 2021 21:02:46 +0100 Subject: [PATCH 06/10] Put wrapping code into its own file --- ts/editor/{filterHtml.ts => htmlFilter.ts} | 4 +- ts/editor/index.ts | 67 +++++----------------- ts/editor/wrap.ts | 41 +++++++++++++ 3 files changed, 57 insertions(+), 55 deletions(-) rename ts/editor/{filterHtml.ts => htmlFilter.ts} (99%) create mode 100644 ts/editor/wrap.ts diff --git a/ts/editor/filterHtml.ts b/ts/editor/htmlFilter.ts similarity index 99% rename from ts/editor/filterHtml.ts rename to ts/editor/htmlFilter.ts index 0b142932d..bb995cbd9 100644 --- a/ts/editor/filterHtml.ts +++ b/ts/editor/htmlFilter.ts @@ -1,6 +1,6 @@ import { nodeIsElement } from "./helpers"; -export let filterHTML = function ( +export function filterHTML( html: string, internal: boolean, extendedMode: boolean @@ -21,7 +21,7 @@ export let filterHTML = function ( } outHtml = outHtml.trim(); return outHtml; -}; +} let allowedTagsBasic = {}; let allowedTagsExtended = {}; diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 81f42b5e6..1483fbb48 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -1,10 +1,10 @@ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ -import { filterHTML } from "./filterHtml"; import { nodeIsInline } from "./helpers"; import { bridgeCommand } from "./lib"; import { saveField } from "./changeTimer"; +import { filterHTML } from "./htmlFilter"; import { updateButtonState, maybeDisableButtons } from "./toolbar"; import { onInput, onKey, onKeyUp } from "./inputHandlers"; import { onFocus, onBlur } from "./focusHandlers"; @@ -12,6 +12,7 @@ import { onFocus, onBlur } from "./focusHandlers"; export { setNoteId, getNoteId } from "./noteId"; export { preventButtonFocus, toggleEditorButton, setFGButton } from "./toolbar"; export { saveNow } from "./changeTimer"; +export { wrap, wrapIntoText } from "./wrap"; declare global { interface Selection { @@ -48,6 +49,18 @@ export function focusIfField(x: number, y: number): boolean { return false; } +export function pasteHTML( + html: string, + internal: boolean, + extendedMode: boolean +): void { + html = filterHTML(html, internal, extendedMode); + + if (html !== "") { + setFormat("inserthtml", html); + } +} + function onPaste(evt: ClipboardEvent): void { bridgeCommand("paste"); evt.preventDefault(); @@ -304,20 +317,6 @@ export function setFonts(fonts: [string, number, boolean][]): void { }); } -function wrappedExceptForWhitespace(text: string, front: string, back: string): string { - const match = text.match(/^(\s*)([^]*?)(\s*)$/)!; - return match[1] + front + match[2] + back + match[3]; -} - -export function wrap(front: string, back: string): void { - wrapInternal(front, back, false); -} - -/* currently unused */ -export function wrapIntoText(front: string, back: string): void { - wrapInternal(front, back, true); -} - export function setFormat(cmd: string, arg?: any, nosave: boolean = false): void { document.execCommand(cmd, false, arg); if (!nosave) { @@ -325,41 +324,3 @@ export function setFormat(cmd: string, arg?: any, nosave: boolean = false): void updateButtonState(); } } - -function wrapInternal(front: string, back: string, plainText: boolean): void { - const currentField = getCurrentField()!; - const s = currentField.getSelection(); - let r = s.getRangeAt(0); - const content = r.cloneContents(); - const span = document.createElement("span"); - span.appendChild(content); - - if (plainText) { - const new_ = wrappedExceptForWhitespace(span.innerText, front, back); - setFormat("inserttext", new_); - } else { - const new_ = wrappedExceptForWhitespace(span.innerHTML, front, back); - setFormat("inserthtml", new_); - } - - if (!span.innerHTML) { - // run with an empty selection; move cursor back past postfix - r = s.getRangeAt(0); - r.setStart(r.startContainer, r.startOffset - back.length); - r.collapse(true); - s.removeAllRanges(); - s.addRange(r); - } -} - -export let pasteHTML = function ( - html: string, - internal: boolean, - extendedMode: boolean -): void { - html = filterHTML(html, internal, extendedMode); - - if (html !== "") { - setFormat("inserthtml", html); - } -}; diff --git a/ts/editor/wrap.ts b/ts/editor/wrap.ts new file mode 100644 index 000000000..33fcdf742 --- /dev/null +++ b/ts/editor/wrap.ts @@ -0,0 +1,41 @@ +import { getCurrentField, setFormat } from "."; + +function wrappedExceptForWhitespace(text: string, front: string, back: string): string { + const match = text.match(/^(\s*)([^]*?)(\s*)$/)!; + return match[1] + front + match[2] + back + match[3]; +} + +function wrapInternal(front: string, back: string, plainText: boolean): void { + const currentField = getCurrentField()!; + const s = currentField.getSelection(); + let r = s.getRangeAt(0); + const content = r.cloneContents(); + const span = document.createElement("span"); + span.appendChild(content); + + if (plainText) { + const new_ = wrappedExceptForWhitespace(span.innerText, front, back); + setFormat("inserttext", new_); + } else { + const new_ = wrappedExceptForWhitespace(span.innerHTML, front, back); + setFormat("inserthtml", new_); + } + + if (!span.innerHTML) { + // run with an empty selection; move cursor back past postfix + r = s.getRangeAt(0); + r.setStart(r.startContainer, r.startOffset - back.length); + r.collapse(true); + s.removeAllRanges(); + s.addRange(r); + } +} + +export function wrap(front: string, back: string): void { + wrapInternal(front, back, false); +} + +/* currently unused */ +export function wrapIntoText(front: string, back: string): void { + wrapInternal(front, back, true); +} From d10383ea9d6ee6734405684bfcc371defa98bac5 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 8 Feb 2021 21:26:37 +0100 Subject: [PATCH 07/10] Give toolbar items a bottom margin to separate the rows when they wrap --- ts/editor/editor.scss | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/ts/editor/editor.scss b/ts/editor/editor.scss index 9caf8a996..30b0af96b 100644 --- a/ts/editor/editor.scss +++ b/ts/editor/editor.scss @@ -56,15 +56,19 @@ body { background: var(--bg-color); } -.topbuts > * { - margin: 0 1px; +.topbuts { + margin-bottom: 2px; - &:first-child { - margin-left: 0; - } + & > * { + margin: 0 1px; - &:last-child { - margin-right: 0; + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } } } From a90931b91e534962de898a7e8f47dd6cfce286dd Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 8 Feb 2021 22:13:49 +0100 Subject: [PATCH 08/10] Refactor wrap code --- ts/editor/wrap.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/ts/editor/wrap.ts b/ts/editor/wrap.ts index 33fcdf742..2408fe0f5 100644 --- a/ts/editor/wrap.ts +++ b/ts/editor/wrap.ts @@ -5,11 +5,19 @@ function wrappedExceptForWhitespace(text: string, front: string, back: string): return match[1] + front + match[2] + back + match[3]; } +function moveCursorPastPostfix(selection: Selection, postfix: string): void { + const range = selection.getRangeAt(0); + range.setStart(range.startContainer, range.startOffset - postfix.length); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +} + function wrapInternal(front: string, back: string, plainText: boolean): void { const currentField = getCurrentField()!; - const s = currentField.getSelection(); - let r = s.getRangeAt(0); - const content = r.cloneContents(); + const selection = currentField.getSelection(); + const range = selection.getRangeAt(0); + const content = range.cloneContents(); const span = document.createElement("span"); span.appendChild(content); @@ -22,12 +30,7 @@ function wrapInternal(front: string, back: string, plainText: boolean): void { } if (!span.innerHTML) { - // run with an empty selection; move cursor back past postfix - r = s.getRangeAt(0); - r.setStart(r.startContainer, r.startOffset - back.length); - r.collapse(true); - s.removeAllRanges(); - s.addRange(r); + moveCursorPastPostfix(selection, back); } } From b56b3fd74a527b265c16ed58eb2b1107217ccdf4 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Mon, 8 Feb 2021 22:18:06 +0100 Subject: [PATCH 09/10] Use mouseup intead of click for updateButtonState --- ts/editor/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 1483fbb48..d621d482b 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -151,7 +151,7 @@ export class EditingArea extends HTMLDivElement { this.addEventListener("paste", onPaste); this.addEventListener("copy", onCutOrCopy); this.addEventListener("oncut", onCutOrCopy); - this.addEventListener("click", updateButtonState); + this.addEventListener("mouseup", updateButtonState); const baseStyleSheet = this.baseStyle.sheet as CSSStyleSheet; baseStyleSheet.insertRule("anki-editable {}", 0); @@ -166,7 +166,7 @@ export class EditingArea extends HTMLDivElement { this.removeEventListener("paste", onPaste); this.removeEventListener("copy", onCutOrCopy); this.removeEventListener("oncut", onCutOrCopy); - this.removeEventListener("click", updateButtonState); + this.removeEventListener("mouseup", updateButtonState); } initialize(color: string, content: string): void { From ee194e951c5f77ff42c10203e9ad40cc3e8453e6 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Tue, 9 Feb 2021 01:09:16 +0100 Subject: [PATCH 10/10] Rework focusing code to fix two issues: 1. Clicking away from the editor window, and back on it should not focus old field 2. Clicking on a field, which is not fully visible, should scroll it into view --- ts/editor/focusHandlers.ts | 42 ++++++++++++++++++++++++++------------ ts/editor/index.ts | 2 +- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/ts/editor/focusHandlers.ts b/ts/editor/focusHandlers.ts index 6e655be5c..df5d0d16b 100644 --- a/ts/editor/focusHandlers.ts +++ b/ts/editor/focusHandlers.ts @@ -1,18 +1,26 @@ -import type { EditingArea } from "."; +import type { EditingArea, EditorField } from "."; import { bridgeCommand } from "./lib"; import { enableButtons, disableButtons } from "./toolbar"; import { saveField } from "./changeTimer"; -function isElementInViewport(element: Element): boolean { +enum ViewportRelativePosition { + Contained, + ExceedTop, + ExceedBottom, +} + +function isFieldInViewport( + element: Element, + toolbarHeight: number +): ViewportRelativePosition { const rect = element.getBoundingClientRect(); - return ( - rect.top >= 0 && - rect.left >= 0 && - rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && - rect.right <= (window.innerWidth || document.documentElement.clientWidth) - ); + return rect.top <= toolbarHeight + ? ViewportRelativePosition.ExceedTop + : rect.bottom >= (window.innerHeight || document.documentElement.clientHeight) + ? ViewportRelativePosition.ExceedBottom + : ViewportRelativePosition.Contained; } function caretToEnd(currentField: EditingArea): void { @@ -34,21 +42,29 @@ export function onFocus(evt: FocusEvent): void { return; } + const editorField = currentField.parentElement! as EditorField; + const toolbarHeight = document.getElementById("topbutsOuter")!.clientHeight; + switch (isFieldInViewport(editorField, toolbarHeight)) { + case ViewportRelativePosition.ExceedBottom: + editorField.scrollIntoView(false); + break; + case ViewportRelativePosition.ExceedTop: + editorField.scrollIntoView(true); + window.scrollBy(0, -toolbarHeight); + break; + } + currentField.focusEditable(); bridgeCommand(`focus:${currentField.ord}`); enableButtons(); // do this twice so that there's no flicker on newer versions caretToEnd(currentField); - // scroll if bottom of element off the screen - if (!isElementInViewport(currentField)) { - currentField.scrollIntoView(false /* alignToBottom */); - } } export function onBlur(evt: FocusEvent): void { const currentField = evt.currentTarget as EditingArea; - if (currentField === previousActiveElement) { + if (currentField === document.activeElement) { // other widget or window focused; current field unchanged saveField(currentField, "key"); previousActiveElement = currentField; diff --git a/ts/editor/index.ts b/ts/editor/index.ts index d621d482b..2abc66412 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -207,7 +207,7 @@ export class EditingArea extends HTMLDivElement { customElements.define("anki-editing-area", EditingArea, { extends: "div" }); -class EditorField extends HTMLDivElement { +export class EditorField extends HTMLDivElement { labelContainer: HTMLDivElement; label: HTMLSpanElement; editingArea: EditingArea;