From 33160dcb00373caa5f5aace69a529c556e8e4032 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Sat, 30 Jan 2021 17:54:07 +0100 Subject: [PATCH] Make editor a rollup package --- qt/aqt/data/web/js/BUILD.bazel | 28 +- qt/aqt/data/web/js/editor/BUILD.bazel | 16 + qt/aqt/data/web/js/editor/filterHtml.ts | 170 +++++++++++ qt/aqt/data/web/js/editor/helpers.ts | 65 +++++ .../web/js/{editor.ts => editor/index.ts} | 274 ++---------------- qt/aqt/data/web/js/rollup.config.js | 15 + 6 files changed, 312 insertions(+), 256 deletions(-) create mode 100644 qt/aqt/data/web/js/editor/BUILD.bazel create mode 100644 qt/aqt/data/web/js/editor/filterHtml.ts create mode 100644 qt/aqt/data/web/js/editor/helpers.ts rename qt/aqt/data/web/js/{editor.ts => editor/index.ts} (72%) create mode 100644 qt/aqt/data/web/js/rollup.config.js diff --git a/qt/aqt/data/web/js/BUILD.bazel b/qt/aqt/data/web/js/BUILD.bazel index b47d90193..24800474f 100644 --- a/qt/aqt/data/web/js/BUILD.bazel +++ b/qt/aqt/data/web/js/BUILD.bazel @@ -1,9 +1,12 @@ load("@npm//@bazel/typescript:index.bzl", "ts_library") +load("@npm//@bazel/rollup:index.bzl", "rollup_bundle") load("//ts:prettier.bzl", "prettier_test") +load("//ts:eslint.bzl", "eslint_test") ts_library( name = "pycmd", srcs = ["pycmd.d.ts"], + visibility = ["//qt/aqt/data/web/js:__subpackages__"], ) ts_library( @@ -26,10 +29,30 @@ filegroup( output_group = "es5_sources", ) +###### aqt bundles + +rollup_bundle( + name = "editor", + config_file = "rollup.config.js", + entry_point = "//qt/aqt/data/web/js/editor:index.ts", + format = "iife", + link_workspace_root = True, + silent = True, + sourcemap = "false", + deps = [ + "//qt/aqt/data/web/js/editor", + "@npm//@rollup/plugin-commonjs", + "@npm//@rollup/plugin-node-resolve", + "@npm//rollup-plugin-terser", + ], +) + + filegroup( name = "js", srcs = [ "aqt_es5", + "editor", "mathjax.js", "//qt/aqt/data/web/js/vendor", ], @@ -47,4 +70,7 @@ prettier_test( # srcs = glob(["*.ts"]), # ) -exports_files(["mathjax.js"]) +exports_files([ + "mathjax.js", + "tsconfig.json", +]) diff --git a/qt/aqt/data/web/js/editor/BUILD.bazel b/qt/aqt/data/web/js/editor/BUILD.bazel new file mode 100644 index 000000000..853ea8471 --- /dev/null +++ b/qt/aqt/data/web/js/editor/BUILD.bazel @@ -0,0 +1,16 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_library") + +ts_library( + name = "editor", + srcs = glob(["*.ts"]), + tsconfig = "//qt/aqt/data/web/js:tsconfig.json", + deps = [ + "//qt/aqt/data/web/js:pycmd", + "@npm//@types/jquery", + ], + visibility = ["//qt:__subpackages__"], +) + +exports_files([ + "index.ts", +]) diff --git a/qt/aqt/data/web/js/editor/filterHtml.ts b/qt/aqt/data/web/js/editor/filterHtml.ts new file mode 100644 index 000000000..0b142932d --- /dev/null +++ b/qt/aqt/data/web/js/editor/filterHtml.ts @@ -0,0 +1,170 @@ +import { nodeIsElement } from "./helpers"; + +export let filterHTML = function ( + html: string, + internal: boolean, + extendedMode: boolean +): string { + // wrap it in as we aren't allowed to change top level elements + const top = document.createElement("ankitop"); + top.innerHTML = html; + + if (internal) { + filterInternalNode(top); + } else { + filterNode(top, extendedMode); + } + let outHtml = top.innerHTML; + if (!extendedMode && !internal) { + // collapse whitespace + outHtml = outHtml.replace(/[\n\t ]+/g, " "); + } + outHtml = outHtml.trim(); + return outHtml; +}; + +let allowedTagsBasic = {}; +let allowedTagsExtended = {}; + +let TAGS_WITHOUT_ATTRS = ["P", "DIV", "BR", "SUB", "SUP"]; +for (const tag of TAGS_WITHOUT_ATTRS) { + allowedTagsBasic[tag] = { attrs: [] }; +} + +TAGS_WITHOUT_ATTRS = [ + "B", + "BLOCKQUOTE", + "CODE", + "DD", + "DL", + "DT", + "EM", + "H1", + "H2", + "H3", + "I", + "LI", + "OL", + "PRE", + "RP", + "RT", + "RUBY", + "STRONG", + "TABLE", + "U", + "UL", +]; +for (const tag of TAGS_WITHOUT_ATTRS) { + allowedTagsExtended[tag] = { attrs: [] }; +} + +allowedTagsBasic["IMG"] = { attrs: ["SRC"] }; + +allowedTagsExtended["A"] = { attrs: ["HREF"] }; +allowedTagsExtended["TR"] = { attrs: ["ROWSPAN"] }; +allowedTagsExtended["TD"] = { attrs: ["COLSPAN", "ROWSPAN"] }; +allowedTagsExtended["TH"] = { attrs: ["COLSPAN", "ROWSPAN"] }; +allowedTagsExtended["FONT"] = { attrs: ["COLOR"] }; + +const allowedStyling = { + color: true, + "background-color": true, + "font-weight": true, + "font-style": true, + "text-decoration-line": true, +}; + +let isNightMode = function (): boolean { + return document.body.classList.contains("nightMode"); +}; + +let filterExternalSpan = function (elem: HTMLElement) { + // filter out attributes + for (const attr of [...elem.attributes]) { + const attrName = attr.name.toUpperCase(); + + if (attrName !== "STYLE") { + elem.removeAttributeNode(attr); + } + } + + // filter styling + for (const name of [...elem.style]) { + const value = elem.style.getPropertyValue(name); + + if ( + !allowedStyling.hasOwnProperty(name) || + // google docs adds this unnecessarily + (name === "background-color" && value === "transparent") || + // ignore coloured text in night mode for now + (isNightMode() && (name === "background-color" || name === "color")) + ) { + elem.style.removeProperty(name); + } + } +}; + +allowedTagsExtended["SPAN"] = filterExternalSpan; + +// add basic tags to extended +Object.assign(allowedTagsExtended, allowedTagsBasic); + +function isHTMLElement(elem: Element): elem is HTMLElement { + return elem instanceof HTMLElement; +} + +// filtering from another field +let filterInternalNode = function (elem: Element) { + if (isHTMLElement(elem)) { + elem.style.removeProperty("background-color"); + elem.style.removeProperty("font-size"); + elem.style.removeProperty("font-family"); + } + // recurse + for (let i = 0; i < elem.children.length; i++) { + const child = elem.children[i]; + filterInternalNode(child); + } +}; + +// filtering from external sources +let filterNode = function (node: Node, extendedMode: boolean): void { + if (!nodeIsElement(node)) { + return; + } + + // descend first, and take a copy of the child nodes as the loop will skip + // elements due to node modifications otherwise + for (const child of [...node.children]) { + filterNode(child, extendedMode); + } + + if (node.tagName === "ANKITOP") { + return; + } + + const tag = extendedMode + ? allowedTagsExtended[node.tagName] + : allowedTagsBasic[node.tagName]; + + if (!tag) { + if (!node.innerHTML || node.tagName === "TITLE") { + node.parentNode.removeChild(node); + } else { + node.outerHTML = node.innerHTML; + } + } else { + if (typeof tag === "function") { + // filtering function provided + tag(node); + } else { + // allowed, filter out attributes + for (const attr of [...node.attributes]) { + const attrName = attr.name.toUpperCase(); + if (tag.attrs.indexOf(attrName) === -1) { + node.removeAttributeNode(attr); + } + } + } + } +}; diff --git a/qt/aqt/data/web/js/editor/helpers.ts b/qt/aqt/data/web/js/editor/helpers.ts new file mode 100644 index 000000000..c2ce1efc1 --- /dev/null +++ b/qt/aqt/data/web/js/editor/helpers.ts @@ -0,0 +1,65 @@ +export function nodeIsElement(node: Node): node is Element { + return node.nodeType === Node.ELEMENT_NODE; +} + +const INLINE_TAGS = [ + "A", + "ABBR", + "ACRONYM", + "AUDIO", + "B", + "BDI", + "BDO", + "BIG", + "BR", + "BUTTON", + "CANVAS", + "CITE", + "CODE", + "DATA", + "DATALIST", + "DEL", + "DFN", + "EM", + "EMBED", + "I", + "IFRAME", + "IMG", + "INPUT", + "INS", + "KBD", + "LABEL", + "MAP", + "MARK", + "METER", + "NOSCRIPT", + "OBJECT", + "OUTPUT", + "PICTURE", + "PROGRESS", + "Q", + "RUBY", + "S", + "SAMP", + "SCRIPT", + "SELECT", + "SLOT", + "SMALL", + "SPAN", + "STRONG", + "SUB", + "SUP", + "SVG", + "TEMPLATE", + "TEXTAREA", + "TIME", + "U", + "TT", + "VAR", + "VIDEO", + "WBR", +]; + +export function nodeIsInline(node: Node): boolean { + return !nodeIsElement(node) || INLINE_TAGS.includes(node.tagName); +} diff --git a/qt/aqt/data/web/js/editor.ts b/qt/aqt/data/web/js/editor/index.ts similarity index 72% rename from qt/aqt/data/web/js/editor.ts rename to qt/aqt/data/web/js/editor/index.ts index 20a1d7dea..8f15811be 100644 --- a/qt/aqt/data/web/js/editor.ts +++ b/qt/aqt/data/web/js/editor/index.ts @@ -1,16 +1,15 @@ /* 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 { nodeIsElement, nodeIsInline } from "./helpers"; + let currentField: EditingArea | null = null; let changeTimer: number | null = null; let currentNoteId: number | null = null; -declare interface String { - format(...args: string[]): string; -} - /* kept for compatibility with add-ons */ -String.prototype.format = function (...args: string[]): string { +(String.prototype as any).format = function (...args: string[]): string { return this.replace(/\{\d+\}/g, (m: string): void => { const match = m.match(/\d+/); @@ -18,11 +17,11 @@ String.prototype.format = function (...args: string[]): string { }); }; -function setFGButton(col: string): void { +export function setFGButton(col: string): void { document.getElementById("forecolor").style.backgroundColor = col; } -function saveNow(keepFocus: boolean): void { +export function saveNow(keepFocus: boolean): void { if (!currentField) { return; } @@ -100,72 +99,6 @@ function onKeyUp(evt: KeyboardEvent): void { } } -function nodeIsElement(node: Node): node is Element { - return node.nodeType === Node.ELEMENT_NODE; -} - -const INLINE_TAGS = [ - "A", - "ABBR", - "ACRONYM", - "AUDIO", - "B", - "BDI", - "BDO", - "BIG", - "BR", - "BUTTON", - "CANVAS", - "CITE", - "CODE", - "DATA", - "DATALIST", - "DEL", - "DFN", - "EM", - "EMBED", - "I", - "IFRAME", - "IMG", - "INPUT", - "INS", - "KBD", - "LABEL", - "MAP", - "MARK", - "METER", - "NOSCRIPT", - "OBJECT", - "OUTPUT", - "PICTURE", - "PROGRESS", - "Q", - "RUBY", - "S", - "SAMP", - "SCRIPT", - "SELECT", - "SLOT", - "SMALL", - "SPAN", - "STRONG", - "SUB", - "SUP", - "SVG", - "TEMPLATE", - "TEXTAREA", - "TIME", - "U", - "TT", - "VAR", - "VIDEO", - "WBR", -]; - -function nodeIsInline(node: Node): boolean { - return !nodeIsElement(node) || INLINE_TAGS.includes(node.tagName); -} - function inListItem(): boolean { const anchor = currentField.getSelection().anchorNode; @@ -179,7 +112,7 @@ function inListItem(): boolean { return inList; } -function insertNewline(): void { +export function insertNewline(): void { if (!inPreEnvironment()) { setFormat("insertText", "\n"); return; @@ -228,12 +161,12 @@ function updateButtonState(): void { // 'col': document.queryCommandValue("forecolor") } -function toggleEditorButton(buttonid: string): void { +export function toggleEditorButton(buttonid: string): void { const button = $(buttonid)[0]; button.classList.toggle("highlighted"); } -function setFormat(cmd: string, arg?: any, nosave: boolean = false): void { +export function setFormat(cmd: string, arg?: any, nosave: boolean = false): void { document.execCommand(cmd, false, arg); if (!nosave) { saveField("key"); @@ -279,7 +212,7 @@ function onFocus(evt: FocusEvent): void { } } -function focusField(n: number): void { +export function focusField(n: number): void { const field = getEditorField(n); if (field) { @@ -287,7 +220,7 @@ function focusField(n: number): void { } } -function focusIfField(x: number, y: number): boolean { +export function focusIfField(x: number, y: number): boolean { const elements = document.elementsFromPoint(x, y); for (let i = 0; i < elements.length; i++) { let elem = elements[i] as EditingArea; @@ -361,7 +294,7 @@ function wrappedExceptForWhitespace(text: string, front: string, back: string): return match[1] + front + match[2] + back + match[3]; } -function preventButtonFocus(): void { +export function preventButtonFocus(): void { for (const element of document.querySelectorAll("button.linkb")) { element.addEventListener("mousedown", (evt: Event) => { evt.preventDefault(); @@ -386,7 +319,7 @@ function maybeDisableButtons(): void { } } -function wrap(front: string, back: string): void { +export function wrap(front: string, back: string): void { wrapInternal(front, back, false); } @@ -527,7 +460,7 @@ class EditingArea extends HTMLDivElement { return this.editable.style.direction === "rtl"; } - getSelection(): Selection { + getSelection(): any { return this.shadowRoot.getSelection(); } @@ -622,7 +555,7 @@ function forEditorField( } } -function setFields(fields: [string, string][]): void { +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 @@ -637,7 +570,7 @@ function setFields(fields: [string, string][]): void { maybeDisableButtons(); } -function setBackgrounds(cols: ("dupe" | "")[]) { +export function setBackgrounds(cols: ("dupe" | "")[]) { forEditorField(cols, (field, value) => field.editingArea.classList.toggle("dupe", value === "dupe") ); @@ -646,17 +579,17 @@ function setBackgrounds(cols: ("dupe" | "")[]) { .classList.toggle("is-inactive", !cols.includes("dupe")); } -function setFonts(fonts: [string, number, boolean][]): void { +export function setFonts(fonts: [string, number, boolean][]): void { forEditorField(fonts, (field, [fontFamily, fontSize, isRtl]) => { field.setBaseStyling(fontFamily, `${fontSize}px`, isRtl ? "rtl" : "ltr"); }); } -function setNoteId(id: number): void { +export function setNoteId(id: number): void { currentNoteId = id; } -let pasteHTML = function ( +export let pasteHTML = function ( html: string, internal: boolean, extendedMode: boolean @@ -667,172 +600,3 @@ let pasteHTML = function ( setFormat("inserthtml", html); } }; - -let filterHTML = function ( - html: string, - internal: boolean, - extendedMode: boolean -): string { - // wrap it in as we aren't allowed to change top level elements - const top = document.createElement("ankitop"); - top.innerHTML = html; - - if (internal) { - filterInternalNode(top); - } else { - filterNode(top, extendedMode); - } - let outHtml = top.innerHTML; - if (!extendedMode && !internal) { - // collapse whitespace - outHtml = outHtml.replace(/[\n\t ]+/g, " "); - } - outHtml = outHtml.trim(); - return outHtml; -}; - -let allowedTagsBasic = {}; -let allowedTagsExtended = {}; - -let TAGS_WITHOUT_ATTRS = ["P", "DIV", "BR", "SUB", "SUP"]; -for (const tag of TAGS_WITHOUT_ATTRS) { - allowedTagsBasic[tag] = { attrs: [] }; -} - -TAGS_WITHOUT_ATTRS = [ - "B", - "BLOCKQUOTE", - "CODE", - "DD", - "DL", - "DT", - "EM", - "H1", - "H2", - "H3", - "I", - "LI", - "OL", - "PRE", - "RP", - "RT", - "RUBY", - "STRONG", - "TABLE", - "U", - "UL", -]; -for (const tag of TAGS_WITHOUT_ATTRS) { - allowedTagsExtended[tag] = { attrs: [] }; -} - -allowedTagsBasic["IMG"] = { attrs: ["SRC"] }; - -allowedTagsExtended["A"] = { attrs: ["HREF"] }; -allowedTagsExtended["TR"] = { attrs: ["ROWSPAN"] }; -allowedTagsExtended["TD"] = { attrs: ["COLSPAN", "ROWSPAN"] }; -allowedTagsExtended["TH"] = { attrs: ["COLSPAN", "ROWSPAN"] }; -allowedTagsExtended["FONT"] = { attrs: ["COLOR"] }; - -const allowedStyling = { - color: true, - "background-color": true, - "font-weight": true, - "font-style": true, - "text-decoration-line": true, -}; - -let isNightMode = function (): boolean { - return document.body.classList.contains("nightMode"); -}; - -let filterExternalSpan = function (elem: HTMLElement) { - // filter out attributes - for (const attr of [...elem.attributes]) { - const attrName = attr.name.toUpperCase(); - - if (attrName !== "STYLE") { - elem.removeAttributeNode(attr); - } - } - - // filter styling - for (const name of [...elem.style]) { - const value = elem.style.getPropertyValue(name); - - if ( - !allowedStyling.hasOwnProperty(name) || - // google docs adds this unnecessarily - (name === "background-color" && value === "transparent") || - // ignore coloured text in night mode for now - (isNightMode() && (name === "background-color" || name === "color")) - ) { - elem.style.removeProperty(name); - } - } -}; - -allowedTagsExtended["SPAN"] = filterExternalSpan; - -// add basic tags to extended -Object.assign(allowedTagsExtended, allowedTagsBasic); - -function isHTMLElement(elem: Element): elem is HTMLElement { - return elem instanceof HTMLElement; -} - -// filtering from another field -let filterInternalNode = function (elem: Element) { - if (isHTMLElement(elem)) { - elem.style.removeProperty("background-color"); - elem.style.removeProperty("font-size"); - elem.style.removeProperty("font-family"); - } - // recurse - for (let i = 0; i < elem.children.length; i++) { - const child = elem.children[i]; - filterInternalNode(child); - } -}; - -// filtering from external sources -let filterNode = function (node: Node, extendedMode: boolean): void { - if (!nodeIsElement(node)) { - return; - } - - // descend first, and take a copy of the child nodes as the loop will skip - // elements due to node modifications otherwise - for (const child of [...node.children]) { - filterNode(child, extendedMode); - } - - if (node.tagName === "ANKITOP") { - return; - } - - const tag = extendedMode - ? allowedTagsExtended[node.tagName] - : allowedTagsBasic[node.tagName]; - - if (!tag) { - if (!node.innerHTML || node.tagName === "TITLE") { - node.parentNode.removeChild(node); - } else { - node.outerHTML = node.innerHTML; - } - } else { - if (typeof tag === "function") { - // filtering function provided - tag(node); - } else { - // allowed, filter out attributes - for (const attr of [...node.attributes]) { - const attrName = attr.name.toUpperCase(); - if (tag.attrs.indexOf(attrName) === -1) { - node.removeAttributeNode(attr); - } - } - } - } -}; diff --git a/qt/aqt/data/web/js/rollup.config.js b/qt/aqt/data/web/js/rollup.config.js new file mode 100644 index 000000000..363cc5ae1 --- /dev/null +++ b/qt/aqt/data/web/js/rollup.config.js @@ -0,0 +1,15 @@ +import resolve from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import { terser } from "rollup-plugin-terser"; + +import process from "process"; +const production = process.env["COMPILATION_MODE"] === "opt"; + +export default { + output: { + name: "globalThis", + extend: true, + format: "iife", + }, + plugins: [resolve({ browser: true }), commonjs(), production && terser()], +};