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;
}
}