Consolidate Lit repositories: figma-to-lit, figma_lit_example, lit-3d-piano, lit-calculator, lit-code-editor, lit-draggable-dom, lit-file-based-routing, lit-force-graph, lit-html-editor, lit-html-table, lit-modules, lit-native, lit-node-editor, lit-sheet-music, lit-starter-ts, lit-vscode-extension, lit-wmr, vite-lit-capacitor, vite-lit-element-starter, vite-rxdb-lit

This commit is contained in:
2026-05-16 22:17:12 -07:00
parent 4dc3c8a3ff
commit 95667e3038
527 changed files with 63872 additions and 54 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;
}
}
+130
View File
@@ -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;
}
}
+15
View File
@@ -0,0 +1,15 @@
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z" fill="url(#paint0_linear)"/>
<path d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z" fill="url(#paint1_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+4
View File
@@ -0,0 +1,4 @@
export * from './components/rich-text';
export * from './components/rich-action';
export * from './components/rich-toolbar';
export * from './components/rich-viewer';
+138
View File
@@ -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();
}
+3
View File
@@ -0,0 +1,3 @@
import { property } from "lit/decorators.js";
export const live = property({ type: Object, hasChanged: () => true });
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />