Introduce editable module
This commit is contained in:
parent
9daf037c0b
commit
9b2378c3d2
@ -21,11 +21,19 @@ copy_files_into_group(
|
||||
name = "editor",
|
||||
srcs = [
|
||||
"editor.css",
|
||||
"editable.css",
|
||||
],
|
||||
package = "//ts/editor",
|
||||
)
|
||||
|
||||
|
||||
copy_files_into_group(
|
||||
name = "editable",
|
||||
srcs = [
|
||||
"editable-build.css",
|
||||
],
|
||||
package = "//ts/editable",
|
||||
)
|
||||
|
||||
copy_files_into_group(
|
||||
name = "reviewer",
|
||||
srcs = [
|
||||
|
102
ts/editable/BUILD.bazel
Normal file
102
ts/editable/BUILD.bazel
Normal file
@ -0,0 +1,102 @@
|
||||
load("@npm//@bazel/typescript:index.bzl", "ts_library")
|
||||
load("//ts/svelte:svelte.bzl", "compile_svelte", "svelte_check")
|
||||
load("//ts:prettier.bzl", "prettier_test")
|
||||
load("//ts:eslint.bzl", "eslint_test")
|
||||
load("//ts:esbuild.bzl", "esbuild")
|
||||
load("//ts:vendor.bzl", "copy_bootstrap_icons", "copy_mdi_icons")
|
||||
load("//ts:compile_sass.bzl", "compile_sass")
|
||||
|
||||
svelte_files = glob(["*.svelte"])
|
||||
|
||||
svelte_names = [f.replace(".svelte", "") for f in svelte_files]
|
||||
|
||||
filegroup(
|
||||
name = "svelte_components",
|
||||
srcs = svelte_names,
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
compile_svelte(
|
||||
name = "svelte",
|
||||
srcs = svelte_files,
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//ts/components",
|
||||
],
|
||||
)
|
||||
|
||||
compile_sass(
|
||||
srcs = [
|
||||
"editable-base.scss",
|
||||
],
|
||||
group = "editable_scss",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//ts/sass:scrollbar_lib",
|
||||
"//ts/sass/codemirror",
|
||||
],
|
||||
)
|
||||
|
||||
ts_library(
|
||||
name = "editable",
|
||||
srcs = glob(["*.ts"]),
|
||||
module_name = "editable",
|
||||
tsconfig = "//ts:tsconfig.json",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//ts/lib",
|
||||
"//ts/sveltelib",
|
||||
"//ts/components",
|
||||
"//ts:image_module_support",
|
||||
"@npm//svelte",
|
||||
] + svelte_names,
|
||||
)
|
||||
|
||||
esbuild(
|
||||
name = "editable-build",
|
||||
args = [
|
||||
"--loader:.svg=text",
|
||||
"--resolve-extensions=.mjs,.js",
|
||||
"--log-level=warning",
|
||||
],
|
||||
entry_point = "index.ts",
|
||||
output_css = "editable-build.css",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"editable_ts",
|
||||
"svelte_components",
|
||||
"//ts/components",
|
||||
"//ts/components:svelte_components",
|
||||
"@npm//protobufjs",
|
||||
],
|
||||
)
|
||||
|
||||
# Tests
|
||||
################
|
||||
|
||||
prettier_test(
|
||||
name = "format_check",
|
||||
srcs = glob([
|
||||
"*.ts",
|
||||
"*.svelte",
|
||||
]),
|
||||
)
|
||||
|
||||
eslint_test(
|
||||
name = "eslint",
|
||||
srcs = glob(
|
||||
[
|
||||
"*.ts",
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
svelte_check(
|
||||
name = "svelte_check",
|
||||
srcs = glob([
|
||||
"*.ts",
|
||||
"*.svelte",
|
||||
]) + [
|
||||
"//ts/components",
|
||||
],
|
||||
)
|
@ -57,3 +57,5 @@ export class EditableContainer extends HTMLDivElement {
|
||||
return this.baseRule!.style.direction === "rtl";
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("anki-editable-container", EditableContainer, { extends: "div" });
|
@ -1,10 +1,19 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { bridgeCommand } from "./lib";
|
||||
import { elementIsBlock, caretToEnd, getBlockElement } from "./helpers";
|
||||
import { inCodable } from "./toolbar";
|
||||
import { wrap } from "./wrap";
|
||||
import { bridgeCommand } from "lib/bridgecommand";
|
||||
import { elementIsBlock, getBlockElement } from "lib/dom";
|
||||
// import { inCodable } from "./toolbar";
|
||||
// import { wrap } from "./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) {
|
||||
@ -37,7 +46,8 @@ export class Editable extends HTMLElement {
|
||||
|
||||
focus(): void {
|
||||
super.focus();
|
||||
inCodable.set(false);
|
||||
// TODO
|
||||
// inCodable.set(false);
|
||||
}
|
||||
|
||||
caretToEnd(): void {
|
||||
@ -45,7 +55,8 @@ export class Editable extends HTMLElement {
|
||||
}
|
||||
|
||||
surroundSelection(before: string, after: string): void {
|
||||
wrap(before, after);
|
||||
// TODO
|
||||
// wrap(before, after);
|
||||
}
|
||||
|
||||
onEnter(event: KeyboardEvent): void {
|
||||
@ -63,3 +74,5 @@ export class Editable extends HTMLElement {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("anki-editable", Editable);
|
5
ts/editable/index.ts
Normal file
5
ts/editable/index.ts
Normal 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
|
||||
|
||||
import "./editable-container";
|
||||
import "./editable";
|
@ -25,18 +25,6 @@ compile_svelte(
|
||||
],
|
||||
)
|
||||
|
||||
compile_sass(
|
||||
srcs = [
|
||||
"editable.scss",
|
||||
],
|
||||
group = "editable_scss",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//ts/sass:scrollbar_lib",
|
||||
"//ts/sass/codemirror",
|
||||
],
|
||||
)
|
||||
|
||||
compile_sass(
|
||||
srcs = [
|
||||
"fields.scss",
|
||||
@ -71,6 +59,7 @@ ts_library(
|
||||
"//ts/lib",
|
||||
"//ts/sveltelib",
|
||||
"//ts/components",
|
||||
"//ts/editable",
|
||||
"//ts/html-filter",
|
||||
"//ts:image_module_support",
|
||||
"@npm//svelte",
|
||||
@ -156,6 +145,7 @@ esbuild(
|
||||
"bootstrap-icons",
|
||||
"mdi-icons",
|
||||
"svelte_components",
|
||||
"//ts/editable",
|
||||
"//ts/components",
|
||||
"//ts/components:svelte_components",
|
||||
"@npm//protobufjs",
|
||||
|
@ -14,7 +14,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import OnlyEditable from "./OnlyEditable.svelte";
|
||||
import CommandIconButton from "./CommandIconButton.svelte";
|
||||
|
||||
import { getCurrentField, getListItem } from "./helpers";
|
||||
import { getListItem } from "lib/dom";
|
||||
import { getCurrentField } from "./helpers";
|
||||
import {
|
||||
ulIcon,
|
||||
olIcon,
|
||||
@ -31,7 +32,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
function outdentListItem() {
|
||||
const currentField = getCurrentField();
|
||||
if (getListItem(currentField.editableContainer.shadowRoot!)) {
|
||||
if (getListItem(currentField!.editableContainer.shadowRoot!)) {
|
||||
document.execCommand("outdent");
|
||||
} else {
|
||||
alert("Indent/unindent currently only works with lists.");
|
||||
@ -40,7 +41,7 @@ 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(currentField!.editableContainer.shadowRoot!)) {
|
||||
document.execCommand("indent");
|
||||
} else {
|
||||
alert("Indent/unindent currently only works with lists.");
|
||||
|
@ -4,12 +4,12 @@ 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 "./helpers";
|
||||
import { nodeIsElement } from "lib/dom";
|
||||
|
||||
export let activeImage: HTMLImageElement | null;
|
||||
export let container: HTMLElement;
|
||||
export let sheet: CSSStyleSheet;
|
||||
|
||||
export let activeImage: HTMLImageElement | null;
|
||||
let active: boolean = false;
|
||||
|
||||
$: {
|
||||
|
@ -88,3 +88,5 @@ export class Codable extends HTMLTextAreaElement {
|
||||
/* default */
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("anki-codable", Codable, { extends: "textarea" });
|
||||
|
@ -8,8 +8,8 @@
|
||||
|
||||
import ImageHandle from "./ImageHandle.svelte";
|
||||
|
||||
import type { EditableContainer } from "./editable-container";
|
||||
import type { Editable } from "./editable";
|
||||
import type { EditableContainer } from "editable/editable-container";
|
||||
import type { Editable } from "editable/editable";
|
||||
import type { Codable } from "./codable";
|
||||
|
||||
import { updateActiveButtons } from "./toolbar";
|
||||
@ -254,3 +254,5 @@ export class EditingArea extends HTMLDivElement {
|
||||
blur();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("anki-editing-area", EditingArea, { extends: "div" });
|
||||
|
@ -65,3 +65,5 @@ export class EditorField extends HTMLDivElement {
|
||||
this.editingArea.setBaseStyling(fontFamily, fontSize, direction);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("anki-editor-field", EditorField, { extends: "div" });
|
||||
|
@ -1,103 +1,12 @@
|
||||
// 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 type { EditingArea } from "./editing-area";
|
||||
|
||||
export function getCurrentField(): EditingArea | null {
|
||||
return document.activeElement?.closest(".field") ?? null;
|
||||
}
|
||||
|
||||
export function nodeIsElement(node: Node): node is Element {
|
||||
return node.nodeType === Node.ELEMENT_NODE;
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
|
||||
const BLOCK_TAGS = [
|
||||
"ADDRESS",
|
||||
"ARTICLE",
|
||||
"ASIDE",
|
||||
"BLOCKQUOTE",
|
||||
"DETAILS",
|
||||
"DIALOG",
|
||||
"DD",
|
||||
"DIV",
|
||||
"DL",
|
||||
"DT",
|
||||
"FIELDSET",
|
||||
"FIGCAPTION",
|
||||
"FIGURE",
|
||||
"FOOTER",
|
||||
"FORM",
|
||||
"H1",
|
||||
"H2",
|
||||
"H3",
|
||||
"H4",
|
||||
"H5",
|
||||
"H6",
|
||||
"HEADER",
|
||||
"HGROUP",
|
||||
"HR",
|
||||
"LI",
|
||||
"MAIN",
|
||||
"NAV",
|
||||
"OL",
|
||||
"P",
|
||||
"PRE",
|
||||
"SECTION",
|
||||
"TABLE",
|
||||
"UL",
|
||||
];
|
||||
|
||||
export function elementIsBlock(element: Element): boolean {
|
||||
return BLOCK_TAGS.includes(element.tagName);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const getAnchorParent =
|
||||
<T extends Element>(predicate: (element: Element) => element is T) =>
|
||||
(currentField: DocumentOrShadowRoot): T | null => {
|
||||
const anchor = currentField.getSelection()?.anchorNode;
|
||||
|
||||
if (!anchor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let anchorParent: T | null = null;
|
||||
let element = nodeIsElement(anchor) ? anchor : anchor.parentElement;
|
||||
|
||||
while (element) {
|
||||
anchorParent = anchorParent || (predicate(element) ? element : null);
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
return anchorParent;
|
||||
};
|
||||
|
||||
const isListItem = (element: Element): element is HTMLLIElement =>
|
||||
window.getComputedStyle(element).display === "list-item";
|
||||
const isParagraph = (element: Element): element is HTMLParamElement =>
|
||||
element.tagName === "P";
|
||||
const isBlockElement = (
|
||||
element: Element
|
||||
): element is HTMLLIElement & HTMLParamElement =>
|
||||
isListItem(element) || isParagraph(element);
|
||||
|
||||
export const getListItem = getAnchorParent(isListItem);
|
||||
export const getParagraph = getAnchorParent(isParagraph);
|
||||
export const getBlockElement = getAnchorParent(isBlockElement);
|
||||
|
||||
export function appendInParentheses(text: string, appendix: string): string {
|
||||
return `${text} (${appendix})`;
|
||||
}
|
||||
|
@ -13,22 +13,23 @@ import type EditorToolbar from "./EditorToolbar.svelte";
|
||||
import type TagEditor from "./TagEditor.svelte";
|
||||
|
||||
import { filterHTML } from "html-filter";
|
||||
import { updateActiveButtons } from "./toolbar";
|
||||
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 "./change-timer";
|
||||
|
||||
import "./fields.css";
|
||||
|
||||
import { saveField } from "./change-timer";
|
||||
|
||||
import { EditorField } from "./editor-field";
|
||||
import { LabelContainer } from "./label-container";
|
||||
import "editable/editable";
|
||||
import "editable/editable-container";
|
||||
import "./label-container";
|
||||
import "./codable";
|
||||
import "./editor-field";
|
||||
import type { EditorField } from "./editor-field";
|
||||
import { EditingArea } from "./editing-area";
|
||||
import { EditableContainer } from "./editable-container";
|
||||
import { Editable } from "./editable";
|
||||
import { Codable } from "./codable";
|
||||
|
||||
import { initToolbar, fieldFocused } from "./toolbar";
|
||||
import { initTagEditor } from "./tag-editor";
|
||||
import { getCurrentField } from "./helpers";
|
||||
@ -50,13 +51,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("anki-editable", Editable);
|
||||
customElements.define("anki-editable-container", EditableContainer, { extends: "div" });
|
||||
customElements.define("anki-codable", Codable, { extends: "textarea" });
|
||||
customElements.define("anki-editing-area", EditingArea, { extends: "div" });
|
||||
customElements.define("anki-label-container", LabelContainer, { extends: "div" });
|
||||
customElements.define("anki-editor-field", EditorField, { extends: "div" });
|
||||
|
||||
if (isApplePlatform()) {
|
||||
registerShortcut(() => bridgeCommand("paste"), "Control+Shift+V");
|
||||
}
|
||||
|
@ -5,9 +5,9 @@
|
||||
@typescript-eslint/no-non-null-assertion: "off",
|
||||
*/
|
||||
|
||||
import { nodeIsElement } from "lib/dom";
|
||||
import { updateActiveButtons } from "./toolbar";
|
||||
import { EditingArea } from "./editing-area";
|
||||
import { nodeIsElement } from "./helpers";
|
||||
import { triggerChangeTimer } from "./change-timer";
|
||||
import { registerShortcut } from "lib/shortcuts";
|
||||
|
||||
|
@ -127,3 +127,5 @@ export class LabelContainer extends HTMLDivElement {
|
||||
this.toggleSticky();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("anki-label-container", LabelContainer, { extends: "div" });
|
||||
|
89
ts/lib/dom.ts
Normal file
89
ts/lib/dom.ts
Normal file
@ -0,0 +1,89 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
export function nodeIsElement(node: Node): node is Element {
|
||||
return node.nodeType === Node.ELEMENT_NODE;
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
|
||||
const BLOCK_TAGS = [
|
||||
"ADDRESS",
|
||||
"ARTICLE",
|
||||
"ASIDE",
|
||||
"BLOCKQUOTE",
|
||||
"DETAILS",
|
||||
"DIALOG",
|
||||
"DD",
|
||||
"DIV",
|
||||
"DL",
|
||||
"DT",
|
||||
"FIELDSET",
|
||||
"FIGCAPTION",
|
||||
"FIGURE",
|
||||
"FOOTER",
|
||||
"FORM",
|
||||
"H1",
|
||||
"H2",
|
||||
"H3",
|
||||
"H4",
|
||||
"H5",
|
||||
"H6",
|
||||
"HEADER",
|
||||
"HGROUP",
|
||||
"HR",
|
||||
"LI",
|
||||
"MAIN",
|
||||
"NAV",
|
||||
"OL",
|
||||
"P",
|
||||
"PRE",
|
||||
"SECTION",
|
||||
"TABLE",
|
||||
"UL",
|
||||
];
|
||||
|
||||
export function elementIsBlock(element: Element): boolean {
|
||||
return BLOCK_TAGS.includes(element.tagName);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const getAnchorParent =
|
||||
<T extends Element>(predicate: (element: Element) => element is T) =>
|
||||
(currentField: DocumentOrShadowRoot): T | null => {
|
||||
const anchor = currentField.getSelection()?.anchorNode;
|
||||
|
||||
if (!anchor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let anchorParent: T | null = null;
|
||||
let element = nodeIsElement(anchor) ? anchor : anchor.parentElement;
|
||||
|
||||
while (element) {
|
||||
anchorParent = anchorParent || (predicate(element) ? element : null);
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
return anchorParent;
|
||||
};
|
||||
|
||||
const isListItem = (element: Element): element is HTMLLIElement =>
|
||||
window.getComputedStyle(element).display === "list-item";
|
||||
const isParagraph = (element: Element): element is HTMLParamElement =>
|
||||
element.tagName === "P";
|
||||
const isBlockElement = (
|
||||
element: Element
|
||||
): element is HTMLLIElement & HTMLParamElement =>
|
||||
isListItem(element) || isParagraph(element);
|
||||
|
||||
export const getListItem = getAnchorParent(isListItem);
|
||||
export const getParagraph = getAnchorParent(isParagraph);
|
||||
export const getBlockElement = getAnchorParent(isBlockElement);
|
@ -14,6 +14,7 @@
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"editable/*": ["../bazel-bin/ts/editable/*"],
|
||||
"lib/*": ["../bazel-bin/ts/lib/*"],
|
||||
"html-filter/*": ["../bazel-bin/ts/html-filter/*"]
|
||||
/* "sveltelib/*": ["../bazel-bin/ts/sveltelib/*"], */
|
||||
|
Loading…
Reference in New Issue
Block a user