feat: Initialize simple-lit-vite project with Lit and Vite setup

- Add README.md for project description and CLI usage
- Create main application component with basic structure in app.tsx
- Implement CodeMirror editor base functionality in editor.base.ts
- Extend CodeMirror editor for JSON support in editor.json.ts
- Add support for multiple languages in editor.ts
- Create utility functions for editor manipulation in editor.utils.ts
- Implement tab key formatting and indentation in tab.ts
- Add Tailwind CSS integration in index.css
- Develop JSON editor web component in json.ts
- Create a template component for rendering in lib.ts
- Set up main entry point in main.ts
- Configure TypeScript settings in tsconfig.json
- Define custom element typings in typings.d.ts
- Configure Vite for library and application builds in vite.config.lib.ts and vite.config.ts
This commit is contained in:
2025-12-18 03:35:17 +08:00
commit 5f6536e758
24 changed files with 3188 additions and 0 deletions

16
src/app.tsx Normal file
View File

@@ -0,0 +1,16 @@
export const App = () => {
return (
<div>
<h1 className="bg-red-200">Hello Vite + React Simple vite</h1>
<div className='card'>
<button type='button' onClick={() => alert('Button clicked!')}>
Click me
</button>
</div>
{/* <kv-template></kv-template> */}
<kv-code-json value='{"key": "value"}' style={{height:'300px'}}></kv-code-json>
</div >
);
};
export { };

View File

@@ -0,0 +1,92 @@
import { basicSetup } from 'codemirror';
import { EditorView } from '@codemirror/view';
import { StateEffect } from '@codemirror/state';
import { ViewUpdate } from '@codemirror/view';
import { formatKeymap } from './extensions/tab.ts';
import EventEmitter from 'eventemitter3';
export type CodeEditor = EditorView & {
emitter?: EventEmitter;
};
let editor: CodeEditor = null;
export type EditorOptions = {
extensions?: any[];
hasBasicSetup?: boolean;
onChange?: (content: string) => void;
};
/**
* 创建单例
* @param el
* @returns
*/
const createEditorInstance = (el?: HTMLDivElement, opts?: EditorOptions) => {
if (editor && el) {
el.appendChild(editor.dom);
return editor;
} else if (editor) {
return editor;
}
const extensions = opts?.extensions || [];
extensions.push(formatKeymap);
const hasBaseicSetup = opts?.hasBasicSetup ?? true;
if (hasBaseicSetup) {
extensions.unshift(basicSetup);
}
const emitter = new EventEmitter();
createOnChangeListener(emitter, opts.onChange).appendTo(extensions);
editor = new EditorView({
extensions: extensions,
parent: el || document.body,
});
editor.emitter = emitter;
editor.dom.style.height = '100%';
return editor as CodeEditor;
};
/**
* 每次都创建新的实例
* @param el
* @returns
*/
export const createEditor = (el: HTMLDivElement, opts?: EditorOptions) => {
const extensions = opts?.extensions || [];
const hasBaseicSetup = opts?.hasBasicSetup ?? true;
if (hasBaseicSetup) {
extensions.unshift(basicSetup);
}
extensions.push(formatKeymap);
const emitter = new EventEmitter();
createOnChangeListener(emitter, opts.onChange).appendTo(extensions);
const editor = new EditorView({
extensions,
parent: el || document.body,
}) as CodeEditor;
editor.emitter = emitter;
editor.dom.style.height = '100%';
return editor as CodeEditor;
};
export const getEditor = () => editor;
export { editor, createEditorInstance };
export const createOnChangeListener = (emitter: EventEmitter, callback?: (content: string) => void) => {
const listener = EditorView.updateListener.of((update: ViewUpdate) => {
if (update.docChanged) {
const editor = update.view;
if (callback) {
callback(editor.state.doc.toString());
}
// 触发自定义事件
emitter.emit('change', editor.state.doc.toString());
}
});
// 返回监听器配置,而不是直接应用它
return {
extension: listener,
appendTo: (ext: any[]) => {
ext.push(listener);
},
};
};

View File

@@ -0,0 +1,33 @@
import { json } from '@codemirror/lang-json';
import * as Base from './editor.base.ts';
/**
* 创建单例
* @param el
* @returns
*/
export const createEditorInstance = (el?: HTMLDivElement, opts?: Base.EditorOptions) => {
return Base.createEditorInstance(el, {
...opts,
extensions: [...(opts?.extensions || []), json()]
});
};
/**
* 每次都创建新的实例
* @param el
* @returns
*/
export const createEditor = (el: HTMLDivElement, opts?: Base.EditorOptions) => {
return Base.createEditor(el, {
...opts,
extensions: [...(opts?.extensions || []), json()]
});
};
export { Base };
export type CodeEditor = Base.CodeEditor;
export type EditorOptions = Base.EditorOptions;
export const editor = Base.editor;

120
src/codemirror/editor.ts Normal file
View File

@@ -0,0 +1,120 @@
import { basicSetup } from 'codemirror';
import { EditorView } from '@codemirror/view';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { html } from '@codemirror/lang-html';
import { markdown } from '@codemirror/lang-markdown';
import { css } from '@codemirror/lang-css';
import { formatKeymap } from './extensions/tab.ts';
import { createOnChangeListener } from './editor.base.ts';
import EventEmitter from 'eventemitter3';
export type CodeEditor = EditorView & {
emitter?: EventEmitter;
};
let editor: CodeEditor = null;
type CreateOpts = {
jsx?: boolean;
typescript?: boolean;
type?: 'javascript' | 'json' | 'html' | 'markdown' | 'css';
hasBasicSetup?: boolean;
extensions?: any[];
hasKeymap?: boolean;
onChange?: (content: string) => void;
};
/**
* 创建单例
* @param el
* @returns
*/
const createEditorInstance = (el?: HTMLDivElement, opts?: CreateOpts) => {
if (editor && el) {
el.appendChild(editor.dom);
return editor;
} else if (editor) {
return editor;
}
const { type = 'javascript' } = opts || {};
const extensions = opts?.extensions || [];
const hasBaseicSetup = opts?.hasBasicSetup ?? true;
const hasKeymap = opts?.hasKeymap ?? true;
if (hasBaseicSetup) {
extensions.unshift(basicSetup);
}
if (hasKeymap) {
extensions.push(formatKeymap);
}
switch (type) {
case 'json':
extensions.push(json());
break;
case 'javascript':
extensions.push(javascript({ jsx: opts?.jsx, typescript: opts?.typescript }));
break;
case 'css':
extensions.push(css());
break;
case 'html':
extensions.push(html());
break;
case 'markdown':
extensions.push(markdown());
break;
}
const emitter = new EventEmitter();
createOnChangeListener(emitter, opts.onChange).appendTo(extensions);
editor = new EditorView({
extensions: extensions,
parent: el || document.body,
});
editor.dom.style.height = '100%';
editor.emitter = emitter;
return editor as CodeEditor;
};
/**
* 每次都创建新的实例
* @param el
* @returns
*/
export const createEditor = (el: HTMLDivElement, opts?: CreateOpts) => {
const { type = 'javascript' } = opts || {};
const extensions = opts?.extensions || [];
const hasBaseicSetup = opts?.hasBasicSetup ?? true;
const hasKeymap = opts?.hasKeymap ?? true;
if (hasBaseicSetup) {
extensions.unshift(basicSetup);
}
if (hasKeymap) {
extensions.push(formatKeymap);
}
switch (type) {
case 'json':
extensions.push(json());
break;
case 'javascript':
extensions.push(javascript({ jsx: opts?.jsx, typescript: opts?.typescript }));
break;
case 'css':
extensions.push(css());
break;
case 'html':
extensions.push(html());
break;
case 'markdown':
extensions.push(markdown());
break;
}
const emitter = new EventEmitter();
createOnChangeListener(emitter, opts.onChange).appendTo(extensions);
const editor = new EditorView({
extensions: extensions,
parent: el || document.body,
}) as CodeEditor;
editor.dom.style.height = '100%';
editor.emitter = emitter;
return editor as CodeEditor;
};
export { editor, createEditorInstance };

View File

@@ -0,0 +1,59 @@
import { EditorView } from '@codemirror/view';
import { CodeEditor } from './editor.base.ts';
type ChainOpts = {
editor?: CodeEditor;
};
export class Chain {
editor: CodeEditor | EditorView;
constructor(opts?: ChainOpts) {
this.editor = opts?.editor;
}
getEditor() {
return this.editor;
}
getContent() {
return this.editor?.state.doc.toString() || '';
}
setContent(content: string) {
if (this.editor) {
this.editor.dispatch({
changes: { from: 0, to: this.editor.state.doc.length, insert: content },
});
}
return this;
}
setEditor(editor: EditorView) {
this.editor = editor;
return this;
}
clearEditor() {
if (this.editor) {
this.editor.dispatch({
changes: { from: 0, to: this.editor.state.doc.length, insert: '' },
});
}
return this;
}
destroy() {
if (this.editor) {
this.editor.destroy();
this.editor = null;
}
return this;
}
static create(opts?: ChainOpts) {
return new Chain(opts);
}
setOnChange(callback: (content: string) => void) {
if (this.editor) {
const editor = this.editor as CodeEditor;
if (editor.emitter) {
editor.emitter.on('change', callback);
return () => {
editor.emitter.off('change', callback);
};
}
}
return () => {};
}
}

View File

@@ -0,0 +1,56 @@
import { EditorView, keymap } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, indentWithTab, insertTab } from '@codemirror/commands';
import prettier from 'prettier';
// import parserBabel from 'prettier/plugins/babel';
import parserEstree from 'prettier/plugins/estree';
// import parserHtml from 'prettier/plugins/html';
import parserTypescript from 'prettier/plugins/typescript';
// 格式化函数
// Function to format the code using Prettier
type FormatCodeOptions = {
type: 'typescript';
plugins?: any[];
};
async function formatCode(view: EditorView, opts?: FormatCodeOptions) {
const editor = view;
const code = editor.state.doc.toString();
const plugins = opts?.plugins || [];
plugins.push(parserEstree);
const parser = opts?.type || 'typescript';
if (parser === 'typescript') {
plugins.push(parserTypescript);
}
try {
const formattedCode = await prettier.format(code, {
parser: parser,
plugins: plugins,
});
editor.dispatch({
changes: {
from: 0,
to: editor.state.doc.length,
insert: formattedCode.trim(),
},
});
} catch (error) {
console.error('Error formatting code:', error);
}
}
export const formatKeymap = keymap.of([
{
// bug, 必须小写
key: 'alt-shift-f', // 快捷键绑定
// mac: 'cmd-shift-f',
run: (view) => {
formatCode(view);
return true; // 表示按键事件被处理
},
},
// indentWithTab, // Tab键自动缩进
{ key: 'Tab', run: insertTab }, // 在光标位置插入Tab字符
...defaultKeymap, // 默认快捷键
]);

1
src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

229
src/json.ts Normal file
View File

@@ -0,0 +1,229 @@
/**
* @file json.ts
* @description JSON编辑器组件 - 基于CodeMirror的JSON代码编辑器Web组件
* @tags json, editor, codemirror, web-component
* @createdAt 2025-12-18
*/
// import { render } from 'lit-html';
// import { html } from 'lit-html';
import { createEditor, editor, CodeEditor } from './codemirror/editor.json.ts';
/**
* KvCodeJson - JSON编辑器自定义元素
* 支持JSON语法高亮、格式化、验证等功能
*/
class KvCodeJson extends HTMLElement {
private editor: CodeEditor | null = null;
private editorContainer: HTMLDivElement | null = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
/**
* 定义可观察的属性
*/
static get observedAttributes() {
return ['value', 'readonly', 'placeholder'];
}
/**
* 组件挂载时初始化
*/
connectedCallback() {
this.render();
this.initializeEditor();
}
/**
* 组件卸载时清理资源
*/
disconnectedCallback() {
if (this.editor) {
this.editor.destroy();
this.editor = null;
}
}
/**
* 属性变化时的回调
*/
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (oldValue === newValue) return;
switch (name) {
case 'value':
this.setValue(newValue);
break;
case 'readonly':
this.setReadOnly(newValue !== null);
break;
}
}
/**
* 获取或创建内联容器div
*/
getInlineDiv(opts?: { prefixId?: string }): HTMLDivElement {
const prefixId = opts?.prefixId || 'kv-json-editor';
let id = this.id;
if (!id) {
id = `${prefixId}-${Math.random().toString(36).substring(2, 9)}`;
this.id = id;
}
const inlineId = `${id}-inline`;
let editor: HTMLDivElement = this.shadowRoot!.querySelector(`#${inlineId}`)!;
if (!editor) {
editor = document.createElement('div') as HTMLDivElement;
editor.id = inlineId;
editor.style.height = '100%';
editor.style.width = '100%';
// 直接添加到 shadow root不使用 slot
this.shadowRoot!.appendChild(editor);
}
return editor as HTMLDivElement;
}
/**
* 初始化CodeMirror编辑器
*/
private initializeEditor(): void {
const container = this.getInlineDiv();
this.editorContainer = container;
// 创建编辑器实例
this.editor = createEditor(container, {
onChange: (content: string) => {
this.dispatchChangeEvent(content);
},
});
// 设置初始值
const initialValue = this.getAttribute('value');
if (initialValue) {
this.setValue(initialValue);
}
// 设置样式
this.applyStyles();
}
/**
* 应用编辑器样式
*/
private applyStyles(): void {
const style = document.createElement('style');
style.textContent = `
:host {
display: block;
width: 100%;
height: 100%;
}
`;
this.shadowRoot!.appendChild(style);
}
/**
* 渲染组件模板
*/
private render(): void {
// 不需要渲染 slot直接在 shadow root 中创建容器
}
/**
* 设置编辑器内容
*/
setValue(value: string): void {
if (!this.editor) return;
try {
const transaction = this.editor.state.update({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: value,
},
});
this.editor.dispatch(transaction);
} catch (error) {
console.error('Failed to set editor value:', error);
}
}
/**
* 获取编辑器内容
*/
getValue(): string {
return this.editor?.state.doc.toString() || '';
}
/**
* 设置只读模式
*/
setReadOnly(readonly: boolean): void {
if (!this.editor) return;
// TODO: 实现只读模式
console.warn('ReadOnly mode not yet implemented');
}
/**
* 格式化JSON内容
*/
formatJson(): void {
try {
const content = this.getValue();
const parsed = JSON.parse(content);
const formatted = JSON.stringify(parsed, null, 2);
this.setValue(formatted);
} catch (error) {
console.error('Failed to format JSON:', error);
this.dispatchErrorEvent('Invalid JSON format');
}
}
/**
* 验证JSON格式
*/
validateJson(): boolean {
try {
JSON.parse(this.getValue());
return true;
} catch (error) {
return false;
}
}
/**
* 派发内容变化事件
*/
private dispatchChangeEvent(content: string): void {
this.dispatchEvent(
new CustomEvent('change', {
detail: { value: content, valid: this.validateJson() },
bubbles: true,
composed: true,
})
);
}
/**
* 派发错误事件
*/
private dispatchErrorEvent(message: string): void {
this.dispatchEvent(
new CustomEvent('error', {
detail: { message },
bubbles: true,
composed: true,
})
);
}
}
// 定义自定义元素
customElements.define('kv-code-json', KvCodeJson);
export { KvCodeJson, createEditor, editor };

43
src/lib.ts Normal file
View File

@@ -0,0 +1,43 @@
import { render } from 'lit-html';
import { html, TemplateResult } from 'lit-html';
class KvTemplate extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
getInlineDiv(opts?: { prefixId?: string }): HTMLDivElement {
const prefixId = opts?.prefixId || 'component';
const slotName = 'container';
let id = this.id;
if (!id) {
id = `${prefixId}-${Math.random().toString(36).substring(2, 9)}`;
this.id = id;
}
const inlineId = `${id}-inline`;
let editor = this.shadowRoot!.querySelector(`#${inlineId}`)!;
if (!editor) {
editor = document.createElement('div');
editor.id = inlineId;
editor.slot = slotName;
const host = document.querySelector(`#${id}`);
host!.appendChild(editor);
}
return editor as HTMLDivElement;
}
private render(): void {
const contentWithHtml = html`
<slot name="container"></slot>
`;
const el = this.getInlineDiv();
render(contentWithHtml, el);
}
}
// Define the custom element globally
customElements.define('kv-template', KvTemplate);

7
src/main.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client';
import './lib'
import { App } from './app.tsx';
import './index.css';
import './json.ts';
createRoot(document.getElementById('root')!).render(App());