fix(lit-components): fix rendering and interaction issues in force-graph, html-table, and draggable-dom
Build and Deploy / build-and-deploy (push) Has been cancelled

- lit-force-graph: Refactored for instance reuse, better lifecycle management, and added dynamic imports for AR/VR modes.
- lit-html-table: Improved visual design, readability of headers, and fixed reactivity in editable mode.
- lit-draggable-dom: Fixed clipping issues by switching to relative/absolute positioning and resolved a panning speed bug caused by CSS variable inheritance.
This commit is contained in:
2026-05-17 00:41:14 -07:00
parent bbdbeb9788
commit b9e42c3ef4
253 changed files with 2555 additions and 4471 deletions
@@ -0,0 +1,96 @@
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import "@material/mwc-icon-button";
import "@material/mwc-icon";
@customElement("rich-action")
export class RichAction extends LitElement {
static styles = css`
* {
--mdc-icon-size: var(--icon-size);
--mdc-icon-button-size: var(--icon-size);
}
section {
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
section * {
margin: 2px;
}
mwc-icon-button[active] {
color: var(--rich-action-active-color);
}
mwc-icon {
cursor: pointer;
}
`;
@property({ type: String }) command = "";
@property({ type: String }) value?: string;
@property({ type: String }) icon = "info";
@property({ type: Boolean }) active = false;
@property({ type: Array, hasChanged: () => true }) values: Option[] = [];
render() {
const { icon, command, value, active, values } = this;
const hasItems = values.length > 0;
return html`<section>
${hasItems
? html` <mwc-icon>${icon}</mwc-icon>
<select
@change=${(e: Event) => {
const event = e as CustomEvent;
const select = event.target as HTMLSelectElement;
const selectedValue = select.value;
if (selectedValue === "--") {
editorCommand("removeFormat", undefined);
} else {
editorCommand(command, selectedValue);
}
}}
>
${values.map(
(v) =>
html`<option value=${v.value} ?selected=${v.value === value}>
${v.name}
</option>`
)}
</select>`
: html`<mwc-icon-button
?active="${active}"
icon=${icon}
@click=${() => {
if (command) {
editorCommand(command, value);
} else {
this.dispatchEvent(
new Event("action", {
bubbles: true,
composed: true,
})
);
}
}}
></mwc-icon-button>`}
<div><slot></slot></div>
</section>`;
}
}
interface Option {
name: string;
value: string;
}
export function editorCommand(command: string, value?: string) {
document.execCommand(command, true, value);
}
declare global {
interface HTMLElementTagNameMap {
"rich-action": RichAction;
}
}
@@ -0,0 +1,130 @@
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { live } from "../utils/live";
import "./rich-toolbar";
import "./rich-viewer";
@customElement("rich-text")
export class RichText extends LitElement {
static styles = css`
:host {
--rich-color: black;
--rich-background: white;
--rich-action-active-color: red;
--icon-size: 24px;
--rich-padding: 8px;
}
main {
height: 100%;
width: 100%;
display: grid;
grid-template-rows: 1fr auto;
grid-template-columns: 1fr;
grid-template-areas:
"viewer"
"toolbar";
}
rich-toolbar {
grid-area: toolbar;
width: 100%;
overflow-x: auto;
background-color: var(--rich-background);
color: var(--rich-color);
border-top: 1px solid var(--rich-color);
}
rich-viewer {
grid-area: viewer;
flex: 1;
width: 100%;
overflow-y: auto;
background-color: var(--rich-background);
color: var(--rich-color);
}
/* @media (hover: hover) and (pointer: fine) {
main {
grid-template-rows: auto 1fr;
grid-template-areas:
"toolbar"
"viewer";
}
rich-toolbar {
border-top: none;
border-bottom: 1px solid var(--rich-color);
}
} */
main {
grid-template-rows: auto 1fr;
grid-template-areas:
"toolbar"
"viewer";
}
rich-toolbar {
border-top: none;
border-bottom: 1px solid var(--rich-color);
}
@media (prefers-color-scheme: dark) {
:host {
--rich-background: black;
--rich-color: white;
}
}
`;
@live selection: Selection | null = null;
@property({ type: Boolean }) readonly = false;
@property({ type: Object, hasChanged: () => true }) node: Element =
document.createElement("div");
render() {
const { selection, readonly, node } = this;
return html`<main>
<rich-toolbar
.selection=${selection}
.node=${node}
@set-content=${(e: Event) => {
const event = e as CustomEvent<string>;
const parser = new DOMParser();
const doc = parser.parseFromString(event.detail, "text/html");
const root = doc.querySelector("body");
this.node.innerHTML = root?.innerHTML ?? "";
this.requestUpdate();
}}
></rich-toolbar>
<rich-viewer
?readonly=${readonly}
@selection=${(e: Event) => {
const event = e as CustomEvent;
this.selection = event.detail;
}}
.node=${node}
>
</rich-viewer>
</main>`;
}
firstUpdated() {
const children = this.children;
if (children.length > 0) {
// Check if <template> is the first child
const template = children[0];
if (template.tagName === "TEMPLATE") {
const content = template.innerHTML.trim();
if (content.length > 0) {
this.node.innerHTML = content;
this.requestUpdate();
}
}
}
}
}
declare global {
interface HTMLElementTagNameMap {
"rich-text": RichText;
}
}
@@ -0,0 +1,284 @@
import { html, css, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { checkFonts } from "../utils/check-fonts";
import { live } from "../utils/live";
import "./rich-action";
import { editorCommand } from "./rich-action";
@customElement("rich-toolbar")
export class RichToolbar extends LitElement {
static styles = css`
header {
width: 100%;
color: var(--rich-color);
display: flex;
flex-direction: row;
}
input[type="color"] {
-webkit-appearance: none;
border: none;
width: var(--icon-size);
height: var(--icon-size);
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
}
`;
@live selection: Selection | null = null;
@query("#fg-color") fgColorInput!: HTMLInputElement;
@query("#bd-color") bdColorInput!: HTMLInputElement;
@state() fileHandle?: any;
@property({ type: Object, hasChanged: () => true }) node!: Element;
render() {
const tags = this.getTags();
return html`<header>
<rich-action icon="format_clear" command="removeFormat"></rich-action>
<rich-action
icon="format_bold"
command="bold"
?active=${tags.includes("b")}
></rich-action>
<rich-action
icon="format_italic"
command="italic"
?active=${tags.includes("i")}
></rich-action>
<rich-action
icon="format_underlined"
command="underline"
?active=${tags.includes("u")}
></rich-action>
<rich-action icon="format_align_left" command="justifyleft"></rich-action>
<rich-action
icon="format_align_center"
command="justifycenter"
></rich-action>
<rich-action
icon="format_align_right"
command="justifyright"
></rich-action>
<rich-action
icon="format_list_numbered"
command="insertorderedlist"
?active=${tags.includes("ol")}
></rich-action>
<rich-action
icon="format_list_bulleted"
command="insertunorderedlist"
?active=${tags.includes("ul")}
></rich-action>
<rich-action
icon="format_quote"
command="formatblock"
value="blockquote"
></rich-action>
<rich-action
icon="format_indent_decrease"
command="outdent"
></rich-action>
<rich-action icon="format_indent_increase" command="indent"></rich-action>
<rich-action
icon="add_link"
?active=${tags.includes("a")}
@action=${() => {
const newLink = prompt("Write the URL here", "https://");
// Check if valid url
if (newLink && newLink.match(/^(http|https):\/\/[^ "]+$/)) {
editorCommand("createlink", newLink);
}
}}
>
</rich-action>
<rich-action
icon="link_off"
?active=${tags.includes("a")}
command="unlink"
>
</rich-action>
<rich-action
icon="format_color_text"
@action=${() => this.fgColorInput.click()}
>
<input
type="color"
id="fg-color"
@input=${(e: Event) => {
const input = e.target as HTMLInputElement;
editorCommand("forecolor", input.value);
}}
/>
</rich-action>
<rich-action
icon="border_color"
@action=${() => this.bdColorInput.click()}
>
<input
type="color"
id="bd-color"
@input=${(e: Event) => {
const input = e.target as HTMLInputElement;
editorCommand("backcolor", input.value);
}}
/>
</rich-action>
<rich-action
icon="title"
command="formatblock"
.values=${[
{ name: "Normal Text", value: "--" },
{ name: "Heading 1", value: "h1" },
{ name: "Heading 2", value: "h2" },
{ name: "Heading 3", value: "h3" },
{ name: "Heading 4", value: "h4" },
{ name: "Heading 5", value: "h5" },
{ name: "Heading 6", value: "h6" },
{ name: "Paragraph", value: "p" },
{ name: "Pre-Formatted", value: "pre" },
]}
></rich-action>
<rich-action
icon="text_format"
command="fontname"
.values=${[
{ name: "Font Name", value: "--" },
...Array.from(checkFonts()).map((font) => ({
name: font,
value: font,
})),
]}
></rich-action>
<rich-action
icon="format_size"
command="fontsize"
.values=${[
{ name: "Font Size", value: "--" },
{ name: "Very Small", value: "1" },
{ name: "Small", value: "2" },
{ name: "Normal", value: "3" },
{ name: "Medium Large", value: "4" },
{ name: "Large", value: "5" },
{ name: "Very Large", value: "6" },
{ name: "Maximum", value: "7" },
]}
></rich-action>
<rich-action icon="undo" command="undo"></rich-action>
<rich-action icon="redo" command="redo"></rich-action>
<rich-action icon="content_cut" command="cut"></rich-action>
<rich-action icon="content_copy" command="copy"></rich-action>
<rich-action icon="content_paste" command="paste"></rich-action>
<rich-action
icon="file_upload"
@action=${async () => {
if ("showOpenFilePicker" in window) {
// File system api
// @ts-ignore
const [fileHandle] = await window.showOpenFilePicker();
this.fileHandle = fileHandle;
if (fileHandle) {
const file = await fileHandle.getFile();
const contents = await file.text();
this.dispatchEvent(
new CustomEvent("set-content", {
detail: contents,
bubbles: true,
composed: true,
})
);
}
} else {
// Fallback to input
const input = document.createElement("input");
input.type = "file";
input.click();
input.onchange = async () => {
const file = input.files![0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
const data = reader.result as string;
this.dispatchEvent(
new CustomEvent("set-content", {
detail: data,
bubbles: true,
composed: true,
})
);
};
reader.readAsText(file);
}
};
}
}}
></rich-action>
<rich-action
icon="file_download"
@action=${async () => {
const contents = this.node.innerHTML;
if (this.fileHandle) {
const writable = await this.fileHandle.createWritable();
await writable.write(
[
`<!DOCTYPE html>`,
`<html lang="en">`,
` <head> </head>`,
` <body>${contents}</body>`,
`</html>`,
].join("\n")
);
await writable.close();
} else {
// Download file
const url = window.URL.createObjectURL(
new Blob([contents], { type: "text/html" })
);
const link = document.createElement("a");
link.href = url;
link.download = "index.html";
link.click();
}
}}
></rich-action>
</header>`;
}
getTags() {
const { selection } = this;
let tags: string[] = [];
if (selection) {
if (selection.type === "Range") {
// @ts-ignore
let parentNode = selection?.baseNode;
if (parentNode) {
const checkNode = () => {
const tag = parentNode?.tagName?.toLowerCase()?.trim();
if (tag) tags.push(tag);
};
while (parentNode != null) {
checkNode();
parentNode = parentNode?.parentNode;
}
}
// Remove root tag
tags.pop();
} else {
const content = this.selection?.toString() || "";
tags = (content.match(/<[^>]+>/g) || [])
.filter((tag) => !tag.startsWith("</"))
.map((tag) => tag.replace(/<|>/g, ""));
}
}
return tags;
}
}
declare global {
interface HTMLElementTagNameMap {
"rich-toolbar": RichToolbar;
}
}
@@ -0,0 +1,69 @@
import { html, css, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators.js";
@customElement("rich-viewer")
export class RichViewer extends LitElement {
static styles = css`
article {
padding: var(--rich-padding);
width: calc(100% - var(--rich-padding) * 2);
height: calc(100% - var(--rich-padding) * 2);
}
article[contenteditable="true"] {
border: none;
outline: none;
}
`;
@query("#content") content!: HTMLDivElement;
@property({ type: Boolean }) readonly = false;
@property({ type: Object, hasChanged: () => true }) node!: Element;
render() {
const { readonly, node } = this;
return html`<article
id="content"
contenteditable=${readonly ? "false" : "true"}
@input=${() => this.updateSelection()}
>
${node}
</article>`;
}
updateSelection() {
// @ts-ignore
const shadowSelection = this.shadowRoot?.getSelection
? // @ts-ignore
this.shadowRoot!.getSelection()
: null;
const selection =
shadowSelection || document.getSelection() || window.getSelection();
this.dispatchEvent(
new CustomEvent("selection", {
detail: selection,
bubbles: true,
composed: true,
})
);
}
firstUpdated() {
document.execCommand("defaultParagraphSeparator", false, "br");
document.addEventListener("selectionchange", () => {
this.updateSelection();
});
window.addEventListener("selectionchange", () => {
this.updateSelection();
});
document.addEventListener("keydown", () => {
this.updateSelection();
});
}
}
declare global {
interface HTMLElementTagNameMap {
"rich-viewer": RichViewer;
}
}
@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import "./components/rich-text";
import "./components/rich-viewer";
describe("Lit HTML Editor Components", () => {
test("registers rich markdown/HTML custom elements successfully", () => {
expect(customElements.get("rich-text")).toBeDefined();
expect(customElements.get("rich-viewer")).toBeDefined();
});
test("mounts rich-viewer correctly and accepts markdown content", async () => {
document.body.innerHTML = '<rich-viewer value="# Hello World"></rich-viewer>';
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("rich-viewer");
expect(element).toBeDefined();
expect(element?.getAttribute("value")).toBe("# Hello World");
});
});
@@ -0,0 +1,4 @@
export * from './components/rich-text';
export * from './components/rich-action';
export * from './components/rich-toolbar';
export * from './components/rich-viewer';
@@ -0,0 +1,138 @@
export function checkFonts(): string[] {
const fontCheck = new Set(
[
// Windows 10
"Arial",
"Arial Black",
"Bahnschrift",
"Calibri",
"Cambria",
"Cambria Math",
"Candara",
"Comic Sans MS",
"Consolas",
"Constantia",
"Corbel",
"Courier New",
"Ebrima",
"Franklin Gothic Medium",
"Gabriola",
"Gadugi",
"Georgia",
"HoloLens MDL2 Assets",
"Impact",
"Ink Free",
"Javanese Text",
"Leelawadee UI",
"Lucida Console",
"Lucida Sans Unicode",
"Malgun Gothic",
"Marlett",
"Microsoft Himalaya",
"Microsoft JhengHei",
"Microsoft New Tai Lue",
"Microsoft PhagsPa",
"Microsoft Sans Serif",
"Microsoft Tai Le",
"Microsoft YaHei",
"Microsoft Yi Baiti",
"MingLiU-ExtB",
"Mongolian Baiti",
"MS Gothic",
"MV Boli",
"Myanmar Text",
"Nirmala UI",
"Palatino Linotype",
"Segoe MDL2 Assets",
"Segoe Print",
"Segoe Script",
"Segoe UI",
"Segoe UI Historic",
"Segoe UI Emoji",
"Segoe UI Symbol",
"SimSun",
"Sitka",
"Sylfaen",
"Symbol",
"Tahoma",
"Times New Roman",
"Trebuchet MS",
"Verdana",
"Webdings",
"Wingdings",
"Yu Gothic",
// macOS
"American Typewriter",
"Andale Mono",
"Arial",
"Arial Black",
"Arial Narrow",
"Arial Rounded MT Bold",
"Arial Unicode MS",
"Avenir",
"Avenir Next",
"Avenir Next Condensed",
"Baskerville",
"Big Caslon",
"Bodoni 72",
"Bodoni 72 Oldstyle",
"Bodoni 72 Smallcaps",
"Bradley Hand",
"Brush Script MT",
"Chalkboard",
"Chalkboard SE",
"Chalkduster",
"Charter",
"Cochin",
"Comic Sans MS",
"Copperplate",
"Courier",
"Courier New",
"Didot",
"DIN Alternate",
"DIN Condensed",
"Futura",
"Geneva",
"Georgia",
"Gill Sans",
"Helvetica",
"Helvetica Neue",
"Herculanum",
"Hoefler Text",
"Impact",
"Lucida Grande",
"Luminari",
"Marker Felt",
"Menlo",
"Microsoft Sans Serif",
"Monaco",
"Noteworthy",
"Optima",
"Palatino",
"Papyrus",
"Phosphate",
"Rockwell",
"Savoye LET",
"SignPainter",
"Skia",
"Snell Roundhand",
"Tahoma",
"Times",
"Times New Roman",
"Trattatello",
"Trebuchet MS",
"Verdana",
"Zapfino",
].sort()
);
const fontAvailable = new Set<string>();
// @ts-ignore
for (const font of fontCheck.values()) {
// @ts-ignore
if (document.fonts.check(`12px "${font}"`)) {
fontAvailable.add(font);
}
}
// @ts-ignore
return fontAvailable.values();
}
@@ -0,0 +1,3 @@
import { property } from "lit/decorators.js";
export const live = property({ type: Object, hasChanged: () => true });