init container

This commit is contained in:
xion 2024-10-16 23:11:36 +08:00
commit e4b62ee40a
24 changed files with 2111 additions and 0 deletions

58
package.json Normal file
View File

@ -0,0 +1,58 @@
{
"name": "@kevisual/container",
"version": "0.0.2",
"description": "",
"main": "dist/index.js",
"publishConfig": {
"access": "public"
},
"private": false,
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js"
},
"./edit": {
"import": "./dist/edit.js",
"require": "./dist/edit.js"
},
"./container.css": {
"import": "./dist/container.css",
"require": "./dist/container.css"
}
},
"scripts": {
"build": "rimraf dist && rollup -c",
"start": "ts-node src/index.ts",
"watch": "rollup -c -w"
},
"files": [
"src",
"dist"
],
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@rollup/plugin-commonjs": "^28.0.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-typescript": "^12.1.0",
"@types/crypto-js": "^4.2.2",
"@types/react": "^18.3.4",
"rimraf": "^6.0.1",
"rollup": "^4.22.4",
"rollup-plugin-copy": "^3.5.0",
"ts-lib": "^0.0.5",
"typescript": "^5.5.4"
},
"peerDependencies": {
"@emotion/css": "^11.13.0",
"crypto-js": "^4.2.0",
"eventemitter3": "^5.0.1"
},
"dependencies": {
"nanoid": "^5.0.7",
"zustand": "^4.5.5"
}
}

10
readme.md Normal file
View File

@ -0,0 +1,10 @@
# Container
```js
const container = new Container({
root: '#cid",
data: data,
showChild: false,
});
container.renderChildren('node-1');
```

46
rollup.config.js Normal file
View File

@ -0,0 +1,46 @@
// rollup.config.js
import typescript from '@rollup/plugin-typescript';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import copy from 'rollup-plugin-copy';
/**
* @type {import('rollup').RollupOptions}
*/
const config1 = {
input: 'src/index.ts', // TypeScript 入口文件
output: {
file: 'dist/index.js', // 输出文件
format: 'es', // 输出格式设置为 ES 模块
},
plugins: [
resolve(), // 使用 @rollup/plugin-node-resolve 解析 node_modules 中的模块
commonjs(), //
typescript({
allowImportingTsExtensions: true,
noEmit: true,
}), // 使用 @rollup/plugin-typescript 处理 TypeScript 文件
],
};
const config2 = {
input: 'src/edit.ts', // TypeScript 入口文件
output: {
file: 'dist/edit.js', // 输出文件
format: 'es', // 输出格式设置为 ES 模块
},
plugins: [
resolve(), // 使用 @rollup/plugin-node-resolve 解析 node_modules 中的模块
commonjs(), //
typescript({
allowImportingTsExtensions: true,
noEmit: true,
}), // 使用 @rollup/plugin-typescript 处理 TypeScript 文件
// 复制/src/container.css 到dist/container.css
copy({
targets: [{ src: 'src/container.css', dest: 'dist' }],
}),
],
};
export default [config1, config2];

74
src/container-edit.ts Normal file
View File

@ -0,0 +1,74 @@
import { Container, ContainerOpts } from './container';
import { addListener } from './listener/dom';
export type ContainerEditOpts = {
edit?: boolean;
mask?: boolean;
} & ContainerOpts;
let isListener = false;
export class ContainerEdit extends Container {
edit?: boolean;
mask?: boolean;
constructor(opts: ContainerEditOpts) {
let { edit, mask, data, ...opts_ } = opts;
let _edit = edit ?? true;
if (_edit) {
data = data.map((item) => {
if (item.shadowRoot) {
item.shadowRoot = false;
}
return item;
});
}
super({ data, ...opts_ });
this.edit = _edit;
this.mask = mask ?? true;
if (_edit) {
if (!isListener) {
isListener = true;
addListener(this.root);
}
}
}
renderChildren(cid: string, parentElement?: any, pid?: any) {
const el = super.renderChildren(cid, parentElement, pid);
if (el) {
const computedStyle = window.getComputedStyle(el);
const { position } = computedStyle;
const isAbsolute = position === 'absolute' || position === 'relative' || position === 'fixed' || position === 'sticky';
if (isAbsolute) {
const elResizer = document.createElement('div');
elResizer.className = 'resizer';
Object.assign(elResizer.style, {
position: 'absolute',
bottom: '-4px',
right: '-4px',
width: '8px',
height: '8px',
cursor: 'nwse-resize',
background: 'white',
borderRadius: '50%',
border: '1px solid #70c0ff',
});
elResizer.dataset.bindId = pid ? pid + '-' + cid : cid;
el.appendChild(elResizer);
const elDragTitle = document.createElement('div');
elDragTitle.className = 'drag-title';
Object.assign(elDragTitle.style, {
position: 'absolute',
top: '-10px',
left: '0',
width: 'calc(100% + 4px)',
height: '10px',
cursor: 'move',
background: '#195ca9',
transform: 'translateX(-2px)',
zIndex: '9',
});
elDragTitle.dataset.bindId = pid ? pid + '-' + cid : cid;
el.appendChild(elDragTitle);
}
}
}
}

354
src/container-store.ts Normal file
View File

@ -0,0 +1,354 @@
import createEmotion from '@emotion/css/create-instance';
import EventEmitter from 'eventemitter3';
import { MD5 } from 'crypto-js';
import { ContainerEvent } from './event/continer';
import { RenderCode } from './render';
import { ContainerStore, createContainerStore } from './store';
import { StoreApi } from 'zustand/vanilla';
type CodeUrl = {
url: string; // 地址
code?: string; // 代码
id?: string; // codeId
hash?: string;
};
export class CodeUrlManager {
urls: CodeUrl[] = [];
urlMap: { [key: string]: CodeUrl } = {};
constructor() {
this.urls = [];
}
addCode(code: string, id: string, hash?: string) {
const urlMap = this.urlMap;
const resultContent = (content: CodeUrl) => {
const index = this.urls.findIndex((item) => item.id === id);
if (index === -1) {
this.urls.push(content);
}
return content;
};
if (urlMap[hash]) {
// 可能id不同但是hash相同。已经加载过了。
const content = {
...urlMap[hash],
id,
};
return resultContent(content);
}
const _hash = hash || MD5(code).toString();
if (urlMap[_hash]) {
const content = {
...urlMap[_hash],
id,
};
return resultContent(content);
}
const index = this.urls.findIndex((item) => item.id === id);
if (index > -1) {
// 没有共同hash的值了但是还是找了id这个时候应该移除之前
const url = this.urls[index];
const list = this.urls.filter((item) => item.hash === url.hash);
if (list.length <= 1) {
// 这个hash的值没有人用了
URL.revokeObjectURL(url.url);
this.urlMap[url.hash] = null;
}
this.urls.splice(index, 1);
}
/**
*
*
*/
const url = URL.createObjectURL(new Blob([code], { type: 'application/javascript' }));
const content = {
id,
url,
code,
hash: _hash,
};
this.urls.push(content);
urlMap[_hash] = { ...content, id: null };
return content;
}
removeUrl(id?: string, url?: string) {
const index = this.urls.findIndex((item) => item.url === url || item.id === id);
if (index > -1) {
const url = this.urls[index];
URL.revokeObjectURL(url.url);
this.urlMap[url.hash] = null;
this.urls.splice(index, 1);
}
}
}
export const codeUrlManager = new CodeUrlManager();
const handleOneCode = async (data: RenderData) => {
const { code } = data;
const urlsBegin = ['http', 'https', 'blob', '/'];
if (typeof code === 'string' && code && urlsBegin.find((item) => code.startsWith(item))) {
return data;
}
if (typeof code === 'string' && data.codeId) {
const codeId = data.codeId;
const { url, hash } = codeUrlManager.addCode(code, codeId, data.hash);
const { render, unmount, ...rest } = await import(/* @vite-ignore */ url);
const _data = {
...data,
codeId,
hash,
code: {
render,
unmount,
...rest,
},
};
return _data;
}
return data;
};
export const handleCode = async (data: RenderData[]) => {
const handleData = [];
for (let i = 0; i < data.length; i++) {
const item = data[i];
const _data = await handleOneCode(item);
handleData.push(_data);
}
return handleData;
};
export type RenderData = {
id: string; // id不会重复
children?: string[];
parents?: string[];
className?: string;
style?: React.CSSProperties | { [key: string]: string };
code: RenderCode | string;
codeId?: string;
shadowRoot?: boolean;
hash?: string;
[key: string]: any;
};
export type ContainerOpts = {
root?: HTMLDivElement | string;
shadowRoot?: ShadowRoot;
destroy?: () => void;
data?: RenderData[];
showChild?: boolean;
};
export class Container {
root: HTMLDivElement;
globalCss: any;
showChild: boolean;
event: EventEmitter;
entryId: string = '';
store: StoreApi<ContainerStore>;
constructor(opts: ContainerOpts) {
const data = opts.data || [];
this.store = createContainerStore();
this.loadData.apply(this, [data]);
const rootElement = typeof opts.root === 'string' ? document.querySelector(opts.root) : opts.root;
this.root = rootElement || (document.body as any);
this.globalCss = createEmotion({ key: 'css-global', speedy: true }).css;
this.showChild = opts.showChild ?? true;
const event = new EventEmitter();
this.event = event;
const listening = this.root.dataset.listening;
if (listening !== 'true') {
this.root.addEventListener('onContainer', (e: ContainerEvent) => {
const { type, data } = e.data;
this.event.emit(type, data);
});
}
}
async loadData(data: RenderData[], key: string = '') {
const store = this.store;
const res = await store.getState().loadData(data);
if (res) {
this.event.emit('loadedData' + key);
} else {
// TODO: loading
}
}
async getData({ id, codeId }: { id?: string; codeId?: string }) {
return await this.store.getState().getData({ id, codeId });
}
async updateDataCode(data: RenderData[]) {
return await this.store.getState().updateDataCode(data);
}
async updateData(data: RenderData[]) {
return await this.store.getState().updateData(data);
}
renderChildren(cid: string, parentElement?: any, pid?: any): void | HTMLDivElement {
const data = this.store.getState().data;
const globalCss = this.globalCss;
if (!parentElement) {
parentElement = this.root;
this.root.dataset.entryId = cid;
this.entryId = cid;
}
const renderChildren = this.renderChildren.bind(this);
const node = data.find((node: RenderData) => node.id === cid);
const event = this.event;
if (!node) {
console.warn('node not found', cid);
return;
}
const { style, code } = node;
const el = document.createElement('div');
const root = parentElement.appendChild(el);
const parentIds = parentElement.dataset.parentIds || '';
const shadowRoot = node.shadowRoot ? el.attachShadow({ mode: 'open' }) : null;
const { css, sheet, cache } = createEmotion({ key: 'css' });
el.dataset.cid = cid;
el.dataset.pid = pid;
el.dataset.parentIds = parentIds ? parentIds + ',' + cid : cid;
if (shadowRoot) {
cache.insert = (selector: string, serialized: any, sheet: any, shouldCache: boolean) => {
const style = document.createElement('style');
style.textContent = selector;
shadowRoot.appendChild(style);
};
}
el.className = !shadowRoot ? globalCss(style as any) : css(style as any);
el.classList.add(cid, 'kv-container');
const { render, unmount } = (code as RenderCode) || {};
let renderRoot = node.shadowRoot ? shadowRoot : root;
if (render) {
render({
root: root,
shadowRoot,
renderRoot,
event,
container: this as any,
code: code as RenderCode,
data: node,
css: shadowRoot ? css : globalCss,
});
} else {
// no render, so no insert
}
if (event) {
const destroy = (id: string) => {
if (id) {
if (id === cid || node?.parents?.find?.((item) => item === id)) {
unmount?.(); // 销毁父亲元素有这个元素的
event.off('destroy', destroy); // 移除监听
} else {
// console.warn('destroy id not found, and not find parentIds', id, 'currentid', cid);
}
} else if (!id) {
unmount?.(); // 所有的都销毁
event.off('destroy', destroy);
}
// 不需要销毁子元素
};
event.on('destroy', destroy);
}
if (shadowRoot) {
const slot = document.createElement('slot');
shadowRoot.appendChild(slot);
}
if (!this.showChild) {
return;
}
const childrenIds = node.children || [];
childrenIds.forEach((childId) => {
renderChildren(childId, root, cid);
});
return el;
}
async destroy(id?: string) {
if (!id) {
this.root.innerHTML = '';
} else {
const el = this.root.querySelector(`[data-cid="${id}"]`);
if (el) {
el.remove();
}
}
this.event.emit('destroy', id);
await new Promise((resolve) => {
setTimeout(() => {
resolve(null);
}, 200);
});
}
/**
*
* @param entryId
* @param data
* @param opts
* @returns
*/
async render(entryId: string, data?: RenderData[], opts: { reload?: boolean } = {}) {
if (this.entryId && this.entryId !== entryId) {
await this.destroy();
} else if (this.entryId) {
this.destroy(entryId);
}
const _data = data || this.store.getState().data;
this.entryId = entryId;
if (opts?.reload) {
this.store.getState().setLoading(true);
await this.loadData.apply(this, [_data]);
}
const loading = this.store.getState().loading;
const loaded = this.store.getState().loaded;
if (loading || !loaded) {
this.event.once('loadedData', () => {
this.renderChildren(entryId);
});
return;
} else {
this.renderChildren(entryId);
}
}
async hotReload(id: string) {
await this.destroy(id);
// const node = document.querySelector(`[data-cid="${id}"]`);
const loading = this.store.getState().loading;
if (loading) {
this.event.once('loadedData', () => {
this.renderChildren(id);
});
return;
} else {
this.renderChildren(id);
}
}
async reRender() {
await this.destroy();
this.renderChildren(this.entryId);
}
async renderId(id: string) {
if (!this.entryId) {
this.render(id);
return;
}
if (id === this.entryId) {
this.reRender();
return;
}
const data = this.store.getState().data;
const node = data.find((item) => item.id === id);
if (node?.parents && node.parents.length > 0) {
const parent = node.parents[node.parents.length - 1];
const parentElement = this.root.querySelector(`[data-cid="${parent}"]`);
await this.destroy(id);
this.renderChildren(id, parentElement, parent);
}
}
close() {
this.destroy();
const event = this.event;
event && event.removeAllListeners();
}
}
export const render = ({ render, unmount }) => {
render({ root: document.body });
};

20
src/container.css Normal file
View File

@ -0,0 +1,20 @@
.kv-container.active {
background: #000 !important;
border: 2px solid #195ca9;
z-index: 100;
}
.kv-container.dragging {
opacity: 0.5; /* 拖动时降低透明度 */
box-sizing: content-box;
border: 2px dashed blue;
}
.kv-container.hover {
cursor: move;
}
.kv-container > .resizer, .kv-container > .drag-title {
display: none;
}
.kv-container.active > .resizer, .kv-container.active > .drag-title {
display: block;
}

416
src/container.ts Normal file
View File

@ -0,0 +1,416 @@
import createEmotion from '@emotion/css/create-instance';
import EventEmitter from 'eventemitter3';
import { MD5 } from 'crypto-js';
import { ContainerEvent } from './event/continer';
import { RenderCode } from './render';
type CodeUrl = {
url: string; // 地址
code?: string; // 代码
id?: string; // codeId
hash?: string;
};
export class CodeUrlManager {
urls: CodeUrl[] = [];
urlMap: { [key: string]: CodeUrl } = {};
constructor() {
this.urls = [];
}
addCode(code: string, id: string, hash?: string) {
const urlMap = this.urlMap;
const resultContent = (content: CodeUrl) => {
const index = this.urls.findIndex((item) => item.id === id);
if (index === -1) {
this.urls.push(content);
}
return content;
};
if (urlMap[hash]) {
// 可能id不同但是hash相同。已经加载过了。
const content = {
...urlMap[hash],
id,
};
return resultContent(content);
}
const _hash = hash || MD5(code).toString();
if (urlMap[_hash]) {
const content = {
...urlMap[_hash],
id,
};
return resultContent(content);
}
const index = this.urls.findIndex((item) => item.id === id);
if (index > -1) {
// 没有共同hash的值了但是还是找了id这个时候应该移除之前
const url = this.urls[index];
const list = this.urls.filter((item) => item.hash === url.hash);
if (list.length <= 1) {
// 这个hash的值没有人用了
URL.revokeObjectURL(url.url);
this.urlMap[url.hash] = null;
}
this.urls.splice(index, 1);
}
/**
*
*
*/
const url = URL.createObjectURL(new Blob([code], { type: 'application/javascript' }));
const content = {
id,
url,
code,
hash: _hash,
};
this.urls.push(content);
urlMap[_hash] = { ...content, id: null };
return content;
}
removeUrl(id?: string, url?: string) {
const index = this.urls.findIndex((item) => item.url === url || item.id === id);
if (index > -1) {
const url = this.urls[index];
URL.revokeObjectURL(url.url);
this.urlMap[url.hash] = null;
this.urls.splice(index, 1);
}
}
}
export const codeUrlManager = new CodeUrlManager();
const handleOneCode = async (data: RenderData) => {
const { code } = data;
const urlsBegin = ['http', 'https', 'blob', '/'];
if (typeof code === 'string' && code && urlsBegin.find((item) => code.startsWith(item))) {
const importContent = await import(/* @vite-ignore */ code);
const { render, unmount, ...rest } = importContent || {};
const _data = {
...data,
code: {
render,
unmount,
...rest,
},
};
return _data;
}
if (typeof code === 'string' && data.codeId) {
const codeId = data.codeId;
const { url, hash } = codeUrlManager.addCode(code, codeId, data.hash);
const importContent = await import(/* @vite-ignore */ url);
const { render, unmount, ...rest } = importContent || {};
const _data = {
...data,
codeId,
hash,
code: {
render,
unmount,
...rest,
},
};
return _data;
}
return data;
};
const handleCode = async (data: RenderData[]) => {
const handleData = [];
for (let i = 0; i < data.length; i++) {
const item = data[i];
const _data = await handleOneCode(item);
handleData.push(_data);
}
return handleData;
};
export type RenderData = {
id: string; // id不会重复
children?: string[];
parents?: string[];
className?: string;
style?: React.CSSProperties | { [key: string]: string };
code: RenderCode | string;
codeId?: string;
shadowRoot?: boolean;
hash?: string;
[key: string]: any;
};
export type ContainerOpts = {
root?: HTMLDivElement | string;
shadowRoot?: ShadowRoot;
destroy?: () => void;
data?: RenderData[];
showChild?: boolean;
};
export class Container {
data: RenderData[];
root: HTMLDivElement;
globalCss: any;
showChild: boolean;
event: EventEmitter;
entryId: string = '';
loading: boolean = false;
loaded: boolean = false;
constructor(opts: ContainerOpts) {
const data = opts.data || [];
this.loadData.apply(this, [data]);
const rootElement = typeof opts.root === 'string' ? document.querySelector(opts.root) : opts.root;
this.root = rootElement || (document.body as any);
this.globalCss = createEmotion({ key: 'css-global', speedy: true }).css;
this.showChild = opts.showChild ?? true;
const event = new EventEmitter();
this.event = event;
const listening = this.root.dataset.listening;
if (listening !== 'true') {
this.root.addEventListener('onContainer', (e: ContainerEvent) => {
const { type, data } = e.data;
this.event.emit(type, data);
});
}
}
async loadData(data: RenderData[], key: string = '') {
this.loading = true;
this.data = await handleCode(data);
this.loading = false;
this.event.emit('loadedData' + key);
this.loaded = true;
}
async getData({ id, codeId }: { id?: string; codeId?: string }) {
if (id && codeId) {
return this.data.find((item) => item.id === id && item.codeId === codeId);
}
if (id) {
return this.data.find((item) => item.id === id);
}
if (codeId) {
return this.data.find((item) => item.codeId === codeId);
}
return null;
}
async updateDataCode(data: RenderData[]) {
if (this.loading) {
console.warn('loading');
return;
}
const _data = this.data.map((item) => {
const node = data.find((node) => node.codeId && node.codeId === item.codeId);
if (node) {
return {
...item,
...node,
};
}
return item;
});
await this.loadData(_data);
}
async updateData(data: RenderData[]) {
if (this.loading) {
return;
}
const _data = this.data.map((item) => {
const node = data.find((node) => node.id === item.id);
if (node) {
return {
...item,
...node,
};
}
return item;
});
console.log('updateData', _data.length, data.length);
await this.loadData(_data);
}
renderChildren(cid: string, parentElement?: any, pid?: any): void | HTMLDivElement {
const data = this.data;
const globalCss = this.globalCss;
if (!parentElement) {
parentElement = this.root;
this.root.dataset.entryId = cid;
this.entryId = cid;
}
const renderChildren = this.renderChildren.bind(this);
const node = data.find((node: RenderData) => node.id === cid);
const event = this.event;
if (!node) {
console.warn('node not found', cid);
return;
}
const { style, code } = node;
const el = document.createElement('div');
const root = parentElement.appendChild(el);
const parentIds = parentElement.dataset.parentIds || '';
const shadowRoot = node.shadowRoot ? el.attachShadow({ mode: 'open' }) : null;
const { css, sheet, cache } = createEmotion({ key: 'css' });
el.dataset.cid = cid;
el.dataset.pid = pid;
el.dataset.parentIds = parentIds ? parentIds + ',' + cid : cid;
if (shadowRoot) {
cache.insert = (selector: string, serialized: any, sheet: any, shouldCache: boolean) => {
const style = document.createElement('style');
style.textContent = selector;
shadowRoot.appendChild(style);
};
}
if (el.style) {
el.className = !shadowRoot ? globalCss(style as any) : css(style as any);
}
el.classList.add(cid, 'kv-container');
const { render, unmount } = (code as RenderCode) || {};
let renderRoot = node.shadowRoot ? shadowRoot : root;
if (render) {
render({
root: root,
shadowRoot,
renderRoot,
event,
container: this,
code: code as RenderCode,
data: node,
css: shadowRoot ? css : globalCss,
});
} else {
// no render, so no insert
}
if (event) {
const destroy = (id: string) => {
if (id) {
if (id === cid || node?.parents?.find?.((item) => item === id)) {
unmount?.(); // 销毁父亲元素有这个元素的
event.off('destroy', destroy); // 移除监听
} else {
// console.warn('destroy id not found, and not find parentIds', id, 'currentid', cid);
}
} else if (!id) {
unmount?.(); // 所有的都销毁
event.off('destroy', destroy);
}
// 不需要销毁子元素
};
event.on('destroy', destroy);
}
if (shadowRoot) {
const slot = document.createElement('slot');
shadowRoot.appendChild(slot);
}
if (!this.showChild) {
return;
}
const childrenIds = node.children || [];
childrenIds.forEach((childId) => {
renderChildren(childId, root, cid);
});
return el;
}
async destroy(id?: string) {
if (!id) {
this.root.innerHTML = '';
} else {
const elements = this.root.querySelectorAll(`[data-cid="${id}"]`);
elements.forEach((element) => element.remove());
}
this.event.emit('destroy', id);
await new Promise((resolve) => {
setTimeout(() => {
resolve(null);
}, 200);
});
}
/**
*
* @param entryId
* @param data
* @param opts
* @returns
*/
async render(entryId: string, data?: RenderData[], opts: { reload?: boolean } = {}) {
if (this.entryId && this.entryId !== entryId) {
await this.destroy();
} else if (this.entryId) {
this.destroy(entryId);
}
const _data = data || this.data;
this.entryId = entryId;
if (opts?.reload) {
this.loading = true;
await this.loadData.apply(this, [_data]);
}
if (this.loading || !this.loaded) {
this.event.once('loadedData', () => {
this.renderChildren(entryId);
});
return;
} else {
this.renderChildren(entryId);
}
}
async hotReload(id: string) {
await this.destroy(id);
// const node = document.querySelector(`[data-cid="${id}"]`);
if (this.loading) {
this.event.once('loadedData', () => {
this.renderChildren(id);
});
return;
} else {
this.renderChildren(id);
}
}
async reRender() {
await this.destroy();
this.renderChildren(this.entryId);
}
async renderId(id: string) {
if (!this.entryId) {
this.render(id);
return;
}
if (id === this.entryId) {
this.reRender();
return;
}
const node = this.data.find((item) => item.id === id);
if (node?.parents && node.parents.length > 0) {
const parent = node.parents[node.parents.length - 1];
const parentElement = this.root.querySelector(`[data-cid="${parent}"]`);
await this.destroy(id);
this.renderChildren(id, parentElement, parent);
}
}
close() {
this.destroy();
const event = this.event;
event && event.removeAllListeners();
}
}
export class ContainerOne extends Container {
constructor(opts: ContainerOpts) {
super(opts);
}
async renderOne({ code: RenderCode }) {
const newData = { id: 'test-render-one', code: RenderCode };
if (this.loading) {
return;
}
if (this.data.length === 0) {
await this.loadData([newData]);
} else {
await this.updateData([newData]);
}
this.hotReload('test-render-one');
}
}
export const mount = ({ render, unmount }, root: string | HTMLDivElement) => {
let _root = typeof root === 'string' ? document.querySelector(root) : root;
_root = _root || (document.body as any);
render({ root: _root });
};

11
src/edit.ts Normal file
View File

@ -0,0 +1,11 @@
export * from './container-edit';
export * from './container';
export * from './render';
export * from './event/unload';
export * from './event/emitter';
export * from './event/continer';

63
src/event/continer.ts Normal file
View File

@ -0,0 +1,63 @@
// 定义自定义事件类,继承自 Event
export class CustomEvent<T extends { [key: string]: any } = {}> extends Event {
data: T;
// 构造函数中接收 type 和自定义的数据
constructor(type: string, data: T, eventInitDict?: EventInit) {
// 调用父类的构造函数
super(type, eventInitDict);
// 初始化自定义的数据
this.data = data;
}
}
type ContainerPosition = {
type: 'position';
data?:
| {
cid?: string;
pid?: string;
rid?: string;
left?: number;
top?: number;
}
| {
cid?: string;
pid?: string;
rid?: string;
transform?: string;
};
};
type ContainerResize = {
type: 'resize';
data?: {
cid?: string;
pid?: string;
rid?: string;
width?: number;
height?: number;
};
};
type ContainerRotate = {
type: 'rotate';
data?: {
cid?: string;
pid?: string;
rid?: string;
rotate?: number;
};
};
type ContainerActive = {
type: 'active';
data?: {
cid?: string;
pid?: string;
rid?: string;
};
};
export type ContainerEventData = ContainerPosition | ContainerResize | ContainerRotate | ContainerActive;
export class ContainerEvent extends CustomEvent<ContainerEventData> {
constructor(type: string, data: ContainerEventData, eventInitDict?: EventInit) {
super(type, data, eventInitDict);
}
}

3
src/event/emitter.ts Normal file
View File

@ -0,0 +1,3 @@
import EventEmitter from 'eventemitter3';
export const emitter = new EventEmitter();

35
src/event/unload.ts Normal file
View File

@ -0,0 +1,35 @@
type UnloadOpts = {
// 传入参数
root?: HTMLDivElement | string;
callback?: () => void;
};
export class Unload {
observer: MutationObserver;
constructor(opts?: UnloadOpts) {
let targetNode: HTMLDivElement;
if (typeof opts?.root === 'string') {
targetNode = document.querySelector(opts?.root)!;
} else {
targetNode = opts?.root!;
}
if (!targetNode) {
console.error('targetNode is not exist');
return;
}
const observer = new MutationObserver((mutationsList, observer) => {
mutationsList.forEach((mutation) => {
mutation.removedNodes.forEach((removedNode) => {
if (removedNode === targetNode) {
opts?.callback?.();
// 在这里处理元素被移除的逻辑
// 停止监听 (当不再需要时)
observer.disconnect();
}
});
});
});
// 配置监听子节点的变化
observer.observe(targetNode.parentNode, { childList: true });
this.observer = observer;
}
}

9
src/index.ts Normal file
View File

@ -0,0 +1,9 @@
export * from './container';
export * from './render';
export * from './event/unload';
export * from './event/emitter';
export * from './event/continer';

295
src/listener/dom.ts Normal file
View File

@ -0,0 +1,295 @@
import { OnResize } from '../position/on-resize';
import { ContainerEvent, ContainerEventData } from '../event/continer';
let offsetX = 0;
let offsetY = 0;
let dragging = false;
let draggingEl: HTMLDivElement | null = null;
let time = 0;
let root: HTMLDivElement | null = null;
let type = 'absolute' as 'absolute' | 'transform';
let rotateValue = 0; // 保存旋转值
let resizering = false;
let resizerEl: HTMLDivElement | null = null;
const onResize = new OnResize(); // 组建拖动缩放旋转
const dispatch = (data: ContainerEventData) => {
if (!data.type) {
return;
}
if (data.data) data.data.rid = root.dataset.entryId;
const event = new ContainerEvent('onContainer', data, {
// type: 'onPosition',
bubbles: true, // 事件是否冒泡
cancelable: true, // 是否可以取消事件的默认动作
composed: true, // 事件是否会在影子DOM根之外触发侦听器
});
root?.dispatchEvent(event);
};
const drag = (e: MouseEvent) => {
if (resizering && resizerEl) {
const x = e.clientX - offsetX;
const y = e.clientY - offsetY;
if (rotateValue > 0) {
// const style = onResize.drag(e);
// console.log('style', style);
}
resizerEl.style.width = `${x}px`;
resizerEl.style.height = `${y}px`;
return;
}
if (draggingEl && type === 'absolute') {
const x = e.clientX - offsetX;
const y = e.clientY - offsetY;
draggingEl.style.left = `${x}px`;
draggingEl.style.top = `${y}px`;
} else if (draggingEl && type === 'transform') {
const x = e.clientX - offsetX;
const y = e.clientY - offsetY;
// translate rotate scale skew preserve
// 更新 translate并保留其他 transform 属性
draggingEl.style.transform = `
translate(${x}px, ${y}px)
rotate(${rotateValue}deg)
`;
}
if (!draggingEl) {
//
const target = e.target as HTMLDivElement;
const closestDataElement = target.closest('[data-cid]');
const clearHover = () => {
const containerHover = root.querySelectorAll('.kv-container.hover');
containerHover.forEach((item) => {
item.classList.remove('hover');
});
};
const el = closestDataElement as HTMLDivElement;
if (el) {
clearHover();
const dataset = el.dataset;
if (dataset.pid !== 'undefined') {
el.classList.add('hover');
}
} else {
clearHover();
}
}
};
const dragEnd = (e) => {
offsetX = 0;
offsetY = 0;
rotateValue = 0;
// let clickTime = Date.now() - time;
if (Date.now() - time < 200) {
dragging = false;
// 无效移动,但是属于点击事件
} else {
setTimeout(() => {
dragging = false;
}, 200);
// 有效移动
if (draggingEl) {
const computedStyle = window.getComputedStyle(draggingEl);
const { left, top, position, transform } = computedStyle;
if (type !== 'transform') {
dispatch({
type: 'position',
data: {
cid: draggingEl.dataset.cid,
pid: draggingEl.dataset.pid,
left: parseInt(left),
top: parseInt(top),
},
});
} else {
const transform = draggingEl.style.transform;
dispatch({
type: 'position',
data: {
cid: draggingEl.dataset.cid,
pid: draggingEl.dataset.pid,
transform,
},
});
}
}
if (resizerEl) {
const computedStyle = window.getComputedStyle(resizerEl);
const { width, height, left, top } = computedStyle;
dispatch({
type: 'resize',
data: {
cid: resizerEl.dataset.cid,
pid: resizerEl.dataset.pid,
width: parseInt(width),
height: parseInt(height),
},
});
}
}
if (draggingEl) {
// 移动完成后清除 dragging 样式
draggingEl.classList.remove('dragging');
}
resizering = false;
resizerEl = null;
draggingEl = null;
time = 0;
};
export const onClick = (e: MouseEvent) => {
if (dragging) {
return;
}
/**
* active
*/
const clearActive = (pop = true) => {
const containerActive = root.querySelectorAll('.kv-container.active');
containerActive.forEach((item) => {
item.classList.remove('active');
});
if (pop) {
dispatch({
type: 'active',
data: null,
});
}
};
const target = e.target as HTMLDivElement;
const closestDataElement = target.closest('[data-cid]') as HTMLDivElement;
// console.log('target', target, closestDataElement);
if (!closestDataElement) {
// console.log('点在了根元素上');
clearActive();
return;
}
if (!dragging) {
clearActive(false);
closestDataElement.classList.add('active');
dispatch({
type: 'active',
data: {
cid: closestDataElement.dataset.cid,
pid: closestDataElement.dataset.pid,
},
});
}
dragging = false;
resizering = false;
};
export const mousedown = (e: MouseEvent) => {
const target = e.target as HTMLDivElement;
// resiver 点击后拖动放大缩小
if (target.classList.contains('resizer')) {
time = Date.now();
resizering = true;
resizerEl = target.parentElement as HTMLDivElement;
const computedStyle = window.getComputedStyle(resizerEl);
const { width, height, left, top } = computedStyle;
if (computedStyle.transform !== 'none') {
const transform = computedStyle.transform;
// 如果旋转了,计算位置
const matrixValues = transform
.match(/matrix\(([^)]+)\)/)[1]
.split(',')
.map(parseFloat);
const [a, b, c, d, tx, ty] = matrixValues;
// 反推计算 rotate, scale, skew
rotateValue = Math.atan2(b, a) * (180 / Math.PI);
onResize.setRotate(rotateValue);
onResize.setPosition({
left: parseInt(left),
top: parseInt(top),
});
onResize.setArea({
width: parseInt(width),
height: parseInt(height),
});
onResize.mousedown(e);
}
offsetX = e.clientX - resizerEl.offsetWidth;
offsetY = e.clientY - resizerEl.offsetHeight;
return;
}
const closestDataElement = target.closest('[data-cid]');
if (!closestDataElement) {
// console.log('点在了根元素上');
return;
}
time = Date.now();
dragging = true;
// console.log('closestDataElement', closestDataElement);
let el = closestDataElement as HTMLDivElement;
el.classList.add('dragging');
const computedStyle = window.getComputedStyle(el);
const position = computedStyle.position;
const transform = computedStyle.transform;
if (position === 'absolute' || position === 'relative' || position === 'fixed' || position === 'sticky') {
type = 'absolute';
} else if (transform !== 'none') {
type = 'transform';
} else {
console.error('position is not absolute or relative or fixed or sticky', 'and transform is none');
el.classList.remove('dragging');
dragging = false;
return;
}
draggingEl = el;
if (type === 'absolute') {
offsetX = e.clientX - el.offsetLeft;
offsetY = e.clientY - el.offsetTop;
}
if (type === 'transform') {
// console.log('transform', transform);
// transform: matrix(1, 0, 0, 1, 0, 0); 2d只能反推出这个 rotate, scaleX, scaleY,skwewX
// transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); 3d
if (transform !== 'none') {
const transformArr = transform.split(',');
offsetX = e.clientX - parseInt(transformArr[4]);
offsetY = e.clientY - parseInt(transformArr[5]);
const matrixValues = transform
.match(/matrix\(([^)]+)\)/)[1]
.split(',')
.map(parseFloat);
const [a, b, c, d, tx, ty] = matrixValues;
// 反推计算 rotate, scale, skew
rotateValue = Math.atan2(b, a) * (180 / Math.PI);
// scaleX = Math.sqrt(a * a + b * b);
// scaleY = Math.sqrt(c * c + d * d);
// skewX = Math.atan2(c, d) * (180 / Math.PI);
} else {
// 没有 transform 的情况下初始化
offsetX = e.clientX - el.offsetLeft;
offsetY = e.clientY - el.offsetTop;
rotateValue = 0;
// scaleX = 1;
// scaleY = 1;
// skewX = 0;
}
}
};
export const addListener = (dom: HTMLDivElement | string) => {
const target = typeof dom === 'string' ? document.querySelector(dom) : dom;
if (!target) {
console.error('target is not exist');
return;
}
root = target as HTMLDivElement;
const listening = root.dataset.listening;
if (root && listening !== 'true') {
root.addEventListener('click', onClick);
root.addEventListener('mousedown', mousedown);
// 鼠标移动时更新位置
root.addEventListener('mousemove', drag);
// // 鼠标松开时结束拖动
root.addEventListener('mouseup', dragEnd);
root.dataset.listening = 'true';
}
};

View File

@ -0,0 +1,288 @@
/* eslint-disable no-lonely-if */
import { calculateRotatedPointCoordinate, getCenterPoint } from './translate';
const funcs = {
lt: calculateLeftTop,
t: calculateTop,
rt: calculateRightTop,
r: calculateRight,
rb: calculateRightBottom,
b: calculateBottom,
lb: calculateLeftBottom,
l: calculateLeft,
};
type FuncKeys = keyof typeof funcs;
function calculateLeftTop(style, curPositon, proportion, needLockProportion, pointInfo) {
const { symmetricPoint } = pointInfo;
let newCenterPoint = getCenterPoint(curPositon, symmetricPoint);
let newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate);
let newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate);
let newWidth = newBottomRightPoint.x - newTopLeftPoint.x;
let newHeight = newBottomRightPoint.y - newTopLeftPoint.y;
if (needLockProportion) {
if (newWidth / newHeight > proportion) {
newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion);
newWidth = newHeight * proportion;
} else {
newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion);
newHeight = newWidth / proportion;
}
// 由于现在求的未旋转前的坐标是以没按比例缩减宽高前的坐标来计算的
// 所以缩减宽高后,需要按照原来的中心点旋转回去,获得缩减宽高并旋转后对应的坐标
// 然后以这个坐标和对称点获得新的中心点,并重新计算未旋转前的坐标
const rotatedTopLeftPoint = calculateRotatedPointCoordinate(newTopLeftPoint, newCenterPoint, style.rotate);
newCenterPoint = getCenterPoint(rotatedTopLeftPoint, symmetricPoint);
newTopLeftPoint = calculateRotatedPointCoordinate(rotatedTopLeftPoint, newCenterPoint, -style.rotate);
newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate);
newWidth = newBottomRightPoint.x - newTopLeftPoint.x;
newHeight = newBottomRightPoint.y - newTopLeftPoint.y;
}
if (newWidth > 0 && newHeight > 0) {
style.width = Math.round(newWidth);
style.height = Math.round(newHeight);
style.left = Math.round(newTopLeftPoint.x);
style.top = Math.round(newTopLeftPoint.y);
}
}
function calculateRightTop(style, curPositon, proportion, needLockProportion, pointInfo) {
const { symmetricPoint } = pointInfo;
let newCenterPoint = getCenterPoint(curPositon, symmetricPoint);
let newTopRightPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate);
let newBottomLeftPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate);
let newWidth = newTopRightPoint.x - newBottomLeftPoint.x;
let newHeight = newBottomLeftPoint.y - newTopRightPoint.y;
if (needLockProportion) {
if (newWidth / newHeight > proportion) {
newTopRightPoint.x -= Math.abs(newWidth - newHeight * proportion);
newWidth = newHeight * proportion;
} else {
newTopRightPoint.y += Math.abs(newHeight - newWidth / proportion);
newHeight = newWidth / proportion;
}
const rotatedTopRightPoint = calculateRotatedPointCoordinate(newTopRightPoint, newCenterPoint, style.rotate);
newCenterPoint = getCenterPoint(rotatedTopRightPoint, symmetricPoint);
newTopRightPoint = calculateRotatedPointCoordinate(rotatedTopRightPoint, newCenterPoint, -style.rotate);
newBottomLeftPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate);
newWidth = newTopRightPoint.x - newBottomLeftPoint.x;
newHeight = newBottomLeftPoint.y - newTopRightPoint.y;
}
if (newWidth > 0 && newHeight > 0) {
style.width = Math.round(newWidth);
style.height = Math.round(newHeight);
style.left = Math.round(newBottomLeftPoint.x);
style.top = Math.round(newTopRightPoint.y);
}
}
function calculateRightBottom(style, curPositon, proportion, needLockProportion, pointInfo) {
const { symmetricPoint } = pointInfo;
let newCenterPoint = getCenterPoint(curPositon, symmetricPoint);
let newTopLeftPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate);
let newBottomRightPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate);
let newWidth = newBottomRightPoint.x - newTopLeftPoint.x;
let newHeight = newBottomRightPoint.y - newTopLeftPoint.y;
if (needLockProportion) {
if (newWidth / newHeight > proportion) {
newBottomRightPoint.x -= Math.abs(newWidth - newHeight * proportion);
newWidth = newHeight * proportion;
} else {
newBottomRightPoint.y -= Math.abs(newHeight - newWidth / proportion);
newHeight = newWidth / proportion;
}
const rotatedBottomRightPoint = calculateRotatedPointCoordinate(newBottomRightPoint, newCenterPoint, style.rotate);
newCenterPoint = getCenterPoint(rotatedBottomRightPoint, symmetricPoint);
newTopLeftPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate);
newBottomRightPoint = calculateRotatedPointCoordinate(rotatedBottomRightPoint, newCenterPoint, -style.rotate);
newWidth = newBottomRightPoint.x - newTopLeftPoint.x;
newHeight = newBottomRightPoint.y - newTopLeftPoint.y;
}
if (newWidth > 0 && newHeight > 0) {
style.width = Math.round(newWidth);
style.height = Math.round(newHeight);
style.left = Math.round(newTopLeftPoint.x);
style.top = Math.round(newTopLeftPoint.y);
}
}
function calculateLeftBottom(style, curPositon, proportion, needLockProportion, pointInfo) {
const { symmetricPoint } = pointInfo;
let newCenterPoint = getCenterPoint(curPositon, symmetricPoint);
let newTopRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate);
let newBottomLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate);
let newWidth = newTopRightPoint.x - newBottomLeftPoint.x;
let newHeight = newBottomLeftPoint.y - newTopRightPoint.y;
if (needLockProportion) {
if (newWidth / newHeight > proportion) {
newBottomLeftPoint.x += Math.abs(newWidth - newHeight * proportion);
newWidth = newHeight * proportion;
} else {
newBottomLeftPoint.y -= Math.abs(newHeight - newWidth / proportion);
newHeight = newWidth / proportion;
}
const rotatedBottomLeftPoint = calculateRotatedPointCoordinate(newBottomLeftPoint, newCenterPoint, style.rotate);
newCenterPoint = getCenterPoint(rotatedBottomLeftPoint, symmetricPoint);
newTopRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate);
newBottomLeftPoint = calculateRotatedPointCoordinate(rotatedBottomLeftPoint, newCenterPoint, -style.rotate);
newWidth = newTopRightPoint.x - newBottomLeftPoint.x;
newHeight = newBottomLeftPoint.y - newTopRightPoint.y;
}
if (newWidth > 0 && newHeight > 0) {
style.width = Math.round(newWidth);
style.height = Math.round(newHeight);
style.left = Math.round(newBottomLeftPoint.x);
style.top = Math.round(newTopRightPoint.y);
}
}
function calculateTop(style, curPositon, proportion, needLockProportion, pointInfo) {
const { symmetricPoint, curPoint } = pointInfo;
// 由于用户拉伸时是以任意角度拉伸的,所以在求得旋转前的坐标时,只取 y 坐标(这里的 x 坐标可能是任意值x 坐标用 curPoint 的。
// 这个中心点(第二个参数)用 curPoint, center, symmetricPoint 都可以,只要他们在一条直线上就行
const rotatedcurPositon = calculateRotatedPointCoordinate(curPositon, curPoint, -style.rotate);
// 算出旋转前 y 坐标,再用 curPoint 的 x 坐标,重新计算它们旋转后对应的坐标
const rotatedTopMiddlePoint = calculateRotatedPointCoordinate(
{
x: curPoint.x,
y: rotatedcurPositon.y,
},
curPoint,
style.rotate,
);
// 用旋转后的坐标和对称点算出新的高度(勾股定理)
const newHeight = Math.sqrt((rotatedTopMiddlePoint.x - symmetricPoint.x) ** 2 + (rotatedTopMiddlePoint.y - symmetricPoint.y) ** 2);
const newCenter = {
x: rotatedTopMiddlePoint.x - (rotatedTopMiddlePoint.x - symmetricPoint.x) / 2,
y: rotatedTopMiddlePoint.y + (symmetricPoint.y - rotatedTopMiddlePoint.y) / 2,
};
let width = style.width;
// 因为调整的是高度 所以只需根据锁定的比例调整宽度即可
if (needLockProportion) {
width = newHeight * proportion;
}
style.width = width;
style.height = Math.round(newHeight);
style.top = Math.round(newCenter.y - newHeight / 2);
style.left = Math.round(newCenter.x - style.width / 2);
}
function calculateRight(style, curPositon, proportion, needLockProportion, pointInfo) {
const { symmetricPoint, curPoint } = pointInfo;
const rotatedcurPositon = calculateRotatedPointCoordinate(curPositon, curPoint, -style.rotate);
const rotatedRightMiddlePoint = calculateRotatedPointCoordinate(
{
x: rotatedcurPositon.x,
y: curPoint.y,
},
curPoint,
style.rotate,
);
const newWidth = Math.sqrt((rotatedRightMiddlePoint.x - symmetricPoint.x) ** 2 + (rotatedRightMiddlePoint.y - symmetricPoint.y) ** 2);
const newCenter = {
x: rotatedRightMiddlePoint.x - (rotatedRightMiddlePoint.x - symmetricPoint.x) / 2,
y: rotatedRightMiddlePoint.y + (symmetricPoint.y - rotatedRightMiddlePoint.y) / 2,
};
let height = style.height;
// 因为调整的是宽度 所以只需根据锁定的比例调整高度即可
if (needLockProportion) {
height = newWidth / proportion;
}
style.height = height;
style.width = Math.round(newWidth);
style.top = Math.round(newCenter.y - style.height / 2);
style.left = Math.round(newCenter.x - newWidth / 2);
}
function calculateBottom(style, curPositon, proportion, needLockProportion, pointInfo) {
const { symmetricPoint, curPoint } = pointInfo;
const rotatedcurPositon = calculateRotatedPointCoordinate(curPositon, curPoint, -style.rotate);
const rotatedBottomMiddlePoint = calculateRotatedPointCoordinate(
{
x: curPoint.x,
y: rotatedcurPositon.y,
},
curPoint,
style.rotate,
);
const newHeight = Math.sqrt((rotatedBottomMiddlePoint.x - symmetricPoint.x) ** 2 + (rotatedBottomMiddlePoint.y - symmetricPoint.y) ** 2);
const newCenter = {
x: rotatedBottomMiddlePoint.x - (rotatedBottomMiddlePoint.x - symmetricPoint.x) / 2,
y: rotatedBottomMiddlePoint.y + (symmetricPoint.y - rotatedBottomMiddlePoint.y) / 2,
};
let width = style.width;
// 因为调整的是高度 所以只需根据锁定的比例调整宽度即可
if (needLockProportion) {
width = newHeight * proportion;
}
style.width = width;
style.height = Math.round(newHeight);
style.top = Math.round(newCenter.y - newHeight / 2);
style.left = Math.round(newCenter.x - style.width / 2);
}
function calculateLeft(style, curPositon, proportion, needLockProportion, pointInfo) {
const { symmetricPoint, curPoint } = pointInfo;
const rotatedcurPositon = calculateRotatedPointCoordinate(curPositon, curPoint, -style.rotate);
const rotatedLeftMiddlePoint = calculateRotatedPointCoordinate(
{
x: rotatedcurPositon.x,
y: curPoint.y,
},
curPoint,
style.rotate,
);
const newWidth = Math.sqrt((rotatedLeftMiddlePoint.x - symmetricPoint.x) ** 2 + (rotatedLeftMiddlePoint.y - symmetricPoint.y) ** 2);
const newCenter = {
x: rotatedLeftMiddlePoint.x - (rotatedLeftMiddlePoint.x - symmetricPoint.x) / 2,
y: rotatedLeftMiddlePoint.y + (symmetricPoint.y - rotatedLeftMiddlePoint.y) / 2,
};
let height = style.height;
if (needLockProportion) {
height = newWidth / proportion;
}
style.height = height;
style.width = Math.round(newWidth);
style.top = Math.round(newCenter.y - style.height / 2);
style.left = Math.round(newCenter.x - newWidth / 2);
}
export default function calculateComponentPositionAndSize(name: FuncKeys, style, curPositon, proportion, needLockProportion, pointInfo) {
funcs[name](style, curPositon, proportion, needLockProportion, pointInfo);
}

View File

@ -0,0 +1,7 @@
export const isString = (value: any, keys: string[]) => {
for (let i = 0; i < keys.length; i++) {
const item = keys[i];
if (typeof value[item] === 'string') return true;
}
return false;
};

95
src/position/on-resize.ts Normal file
View File

@ -0,0 +1,95 @@
import calculateComponentPositionAndSize from './calculateComponentPositionAndSize';
export class OnResize {
dragging = false;
startShapePoint: { x: number; y: number } | null = null;
position: { left: number; top: number } | null = null;
area: { width: number; height: number } | null = null;
rotate = 0;
constructor() {
this.dragging = false;
this.startShapePoint = null;
this.position = null;
this.area = null;
this.rotate = 0;
}
drag(e: MouseEvent) {
const { dragging, startShapePoint, position, area, rotate } = this;
if (dragging && startShapePoint && position && area) {
const { left, top } = position;
const { width, height } = area;
const rect = { left, top, width, height };
// 组件宽高比
const proportion = rect.width / rect.height;
const style = { left, top, width, height, rotate };
// 组件中心点
const center = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
const editor = document.querySelector('.editor') as HTMLElement;
// 获取画布位移信息
const editorRectInfo = editor.getBoundingClientRect();
// 当前点击圆点相对于画布的中心坐标
const curPoint = startShapePoint;
// 获取对称点的坐标
const symmetricPoint = {
x: center.x - (curPoint.x - center.x),
y: center.y - (curPoint.y - center.y),
};
const needLockProportion = false; // 是否是锁定宽高比形变
const curPosition = {
x: e.clientX - Math.round(editorRectInfo.left),
y: e.clientY - Math.round(editorRectInfo.top),
};
calculateComponentPositionAndSize('rb', style, curPosition, proportion, needLockProportion, {
center,
curPoint,
symmetricPoint,
});
console.log('style', style);
return style;
}
}
mousedown(e: MouseEvent) {
const event: any = e;
// 获取初始中心点
const editor = document.querySelector('.editor') as HTMLElement;
const editorRectInfo = editor.getBoundingClientRect();
const pointRect = event.target.getBoundingClientRect();
// 当前点击圆点相对于画布的中心坐标
const startShapePoint = {
x: Math.round(pointRect.left - editorRectInfo.left + event.target.offsetWidth / 2),
y: Math.round(pointRect.top - editorRectInfo.top + event.target.offsetHeight / 2),
};
this.startShapePoint = startShapePoint;
this.dragging = true;
}
mouseup(e: MouseEvent) {
this.dragging = false;
this.startShapePoint = null;
this.clear();
}
setDragging(dragging: boolean) {
this.dragging = dragging;
}
setStartShapePoint(startShapePoint: { x: number; y: number }) {
this.startShapePoint = startShapePoint;
}
setPosition(position: { left: number; top: number }) {
this.position = position;
}
setArea(area: { width: number; height: number }) {
this.area = area;
}
setRotate(rotate: number) {
this.rotate = rotate;
}
clear() {
this.dragging = false;
this.startShapePoint = null;
this.position = null;
this.area = null;
this.rotate = 0;
}
}

146
src/position/translate.ts Normal file
View File

@ -0,0 +1,146 @@
// import { divide, multiply } from 'mathjs';
import { Point } from './type';
// 角度转弧度
// Math.PI = 180 度
function angleToRadian(angle: number) {
return (angle * Math.PI) / 180;
}
/**
*
* @param {Object} point
* @param {Object} center
* @param {Number} rotate
* @return {Object}
* https://www.zhihu.com/question/67425734/answer/252724399 旋转矩阵公式
* https://github.com/shenhudong/snapping-demo/blob/b13de898f5ca879062137c742b5b1c8dd850e4d5/src/common.js#L56
*/
export function calculateRotatedPointCoordinate(point: Point, center: Point, rotate: number) {
/**
*
* a(x, y)
* c(x, y)
* n(x, y)
* θ tan ??
* nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx
* ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy
*/
return {
x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,
y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,
};
}
/**
*
* @param {Object} point
* @param {Object} center
* @param {Int} rotate
* @return {Object}
*/
export const getRotatePoint = calculateRotatedPointCoordinate;
/**
*
* @param {Object} style
* @param {Object} center
* @param {String} name
* @return {Object}
*/
export function getRotatedPointCoordinate(style, center, name) {
let point; // point 是未旋转前的坐标
switch (name) {
case 't':
point = {
x: style.left + style.width / 2,
y: style.top,
};
break;
case 'b':
point = {
x: style.left + style.width / 2,
y: style.top + style.height,
};
break;
case 'l':
point = {
x: style.left,
y: style.top + style.height / 2,
};
break;
case 'r':
point = {
x: style.left + style.width,
y: style.top + style.height / 2,
};
break;
case 'lt':
point = {
x: style.left,
y: style.top,
};
break;
case 'rt':
point = {
x: style.left + style.width,
y: style.top,
};
break;
case 'lb':
point = {
x: style.left,
y: style.top + style.height,
};
break;
default: // rb
point = {
x: style.left + style.width,
y: style.top + style.height,
};
break;
}
return calculateRotatedPointCoordinate(point, center, style.rotate);
}
/**
* 线
* @param {Object} p1 1
* @param {Object} p2 2
* @return {Object}
*/
export const getCenterPoint = (p1: Point, p2: Point) => {
return {
x: p1.x + (p2.x - p1.x) / 2,
y: p1.y + (p2.y - p1.y) / 2,
};
};
export function sin(rotate) {
return Math.abs(Math.sin(angleToRadian(rotate)));
}
export function cos(rotate) {
return Math.abs(Math.cos(angleToRadian(rotate)));
}
export function mod360(deg) {
return (deg + 360) % 360;
}
// export function changeStyleWithScale(value) {
// return multiply(value, divide(parseInt(store.state.canvasStyleData.scale), 100));
// }
export function toPercent(val) {
return val * 100 + '%';
}

25
src/position/type.ts Normal file
View File

@ -0,0 +1,25 @@
export type Point = {
x: number;
y: number;
};
export type Position = {
left: number;
top: number;
width?: number;
height?: number;
rotate?: number;
zIndex?: number;
lock?: boolean;
};
export type Area = {
width: number;
height: number;
};
export type PositionData = {
left?: number;
top?: number;
width?: number;
height?: number;
rotate?: number;
};
export type Direction = 'lt' | 't' | 'rt' | 'r' | 'rb' | 'b' | 'lb' | 'l';

24
src/render.ts Normal file
View File

@ -0,0 +1,24 @@
import EventEmitter from 'eventemitter3';
import type { Container } from './container';
import type { ContainerEdit } from './container-edit';
import { Emotion } from '@emotion/css/create-instance';
export type RenderContext<T extends { [key: string]: any } = {}> = {
root?: HTMLDivElement;
shadowRoot?: ShadowRoot;
container?: Container | ContainerEdit;
event?: EventEmitter;
code?: {
render: Render;
unmount?: Unmount;
[key: string]: (...args: any[]) => any;
};
css?: Emotion['css'];
} & T;
export type RenderCode = {
render: Render;
unmount?: Unmount;
[key: string]: any;
};
type Render = <T extends { [key: string]: any }>(ctx: RenderContext<T>) => Promise<any>;
type Unmount = <T extends { [key: string]: any }>(ctx?: RenderContext<T>) => Promise<any>;

86
src/store/index.ts Normal file
View File

@ -0,0 +1,86 @@
import { handleCode, RenderData } from '../container';
import { createStore } from 'zustand/vanilla';
export type ContainerStore = {
loading: boolean;
loaded: boolean;
setLoading: (loading: boolean) => void;
data: RenderData[];
setData: (data: RenderData[]) => void;
getData: (opt: { id?: string; codeId?: string }) => Promise<any>;
updateData: (newData: RenderData[]) => Promise<void>;
updateDataCode: (newData: RenderData[]) => Promise<void>;
loadData: (data: RenderData[]) => Promise<RenderData[] | boolean>;
};
export const createContainerStore = () => {
const containerStore = createStore<ContainerStore>((set, get) => {
return {
loading: false,
setLoading: (loading) => set({ loading }),
loaded: false,
data: [],
setData: (data) => set({ data }),
getData: async ({ id, codeId }: { id?: string; codeId?: string }) => {
const { data } = get();
if (id && codeId) {
return data.find((item) => item.id === id && item.codeId === codeId);
}
if (id) {
return data.find((item) => item.id === id);
}
if (codeId) {
return data.find((item) => item.codeId === codeId);
}
return null;
},
loadData: async (data: RenderData[]) => {
const { loading } = get();
if (loading) {
return false;
}
set({ loading: true });
const newData = await handleCode(data);
set({ data: newData, loaded: true, loading: false });
return newData;
},
updateDataCode: async (newData: RenderData[]) => {
const { loading, data, loadData } = get();
if (loading) {
console.warn('loading');
return;
}
const _data = data.map((item) => {
const node = newData.find((node) => node.codeId && node.codeId === item.codeId);
if (node) {
return {
...item,
...node,
};
}
return item;
});
await loadData(_data);
set({ data: _data });
},
updateData: async (newData: RenderData[]) => {
const { loading, data, loadData } = get();
if (loading) {
console.warn('loading');
return;
}
const _data = data.map((item) => {
const node = newData.find((node) => node.id === item.id);
if (node) {
return {
...item,
...node,
};
}
return item;
});
await loadData(_data);
set({ data: _data });
},
};
});
return containerStore;
};

0
src/utils/el.ts Normal file
View File

9
src/utils/generate.ts Normal file
View File

@ -0,0 +1,9 @@
import { customAlphabet } from 'nanoid';
const alphabetNumber = '0123456789';
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
export const generateCodeUrl = customAlphabet(alphabet + alphabetNumber, 8);
export const generateId = () => {
return 'g-' + generateCodeUrl(8);
};

14
src/utils/get-tree.ts Normal file
View File

@ -0,0 +1,14 @@
import { RenderData } from '../container';
export const getTree = (data: RenderData[], id: string) => {
const node = data.find((node) => node.id === id);
if (!node) {
return null;
}
const children = node.children || [];
const childNodes = children.map((childId) => getTree(data, childId));
return {
...node,
children: childNodes,
};
};

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"noEmit": false,
"outDir": "dist",
"strict": false,
"baseUrl": ".",
"rootDir": "src",
"noUnusedLocals": false,
"noUnusedParameters": false,
"moduleResolution": "node",
"declaration": true
},
"include": [
"src/**/*.ts"
]
}