commit e4b62ee40a0df710c8e2d0694cd6dcc87a9c1b66 Author: xion Date: Wed Oct 16 23:11:36 2024 +0800 init container diff --git a/package.json b/package.json new file mode 100644 index 0000000..2750bef --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..f0f4c73 --- /dev/null +++ b/readme.md @@ -0,0 +1,10 @@ +# Container + +```js + const container = new Container({ + root: '#cid", + data: data, + showChild: false, + }); + container.renderChildren('node-1'); +``` diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..5889979 --- /dev/null +++ b/rollup.config.js @@ -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]; diff --git a/src/container-edit.ts b/src/container-edit.ts new file mode 100644 index 0000000..8e67ead --- /dev/null +++ b/src/container-edit.ts @@ -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); + } + } + } +} diff --git a/src/container-store.ts b/src/container-store.ts new file mode 100644 index 0000000..fcf9d11 --- /dev/null +++ b/src/container-store.ts @@ -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; + 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 }); +}; diff --git a/src/container.css b/src/container.css new file mode 100644 index 0000000..9caad27 --- /dev/null +++ b/src/container.css @@ -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; +} + diff --git a/src/container.ts b/src/container.ts new file mode 100644 index 0000000..9af6470 --- /dev/null +++ b/src/container.ts @@ -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 }); +}; diff --git a/src/edit.ts b/src/edit.ts new file mode 100644 index 0000000..90db63c --- /dev/null +++ b/src/edit.ts @@ -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'; diff --git a/src/event/continer.ts b/src/event/continer.ts new file mode 100644 index 0000000..a115d17 --- /dev/null +++ b/src/event/continer.ts @@ -0,0 +1,63 @@ +// 定义自定义事件类,继承自 Event +export class CustomEvent 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 { + constructor(type: string, data: ContainerEventData, eventInitDict?: EventInit) { + super(type, data, eventInitDict); + } +} diff --git a/src/event/emitter.ts b/src/event/emitter.ts new file mode 100644 index 0000000..c4aa932 --- /dev/null +++ b/src/event/emitter.ts @@ -0,0 +1,3 @@ +import EventEmitter from 'eventemitter3'; + +export const emitter = new EventEmitter(); diff --git a/src/event/unload.ts b/src/event/unload.ts new file mode 100644 index 0000000..4993b2b --- /dev/null +++ b/src/event/unload.ts @@ -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; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4497ca9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,9 @@ +export * from './container'; + +export * from './render'; + +export * from './event/unload'; + +export * from './event/emitter'; + +export * from './event/continer'; diff --git a/src/listener/dom.ts b/src/listener/dom.ts new file mode 100644 index 0000000..660c81e --- /dev/null +++ b/src/listener/dom.ts @@ -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'; + } +}; diff --git a/src/position/calculateComponentPositionAndSize.ts b/src/position/calculateComponentPositionAndSize.ts new file mode 100644 index 0000000..c06eedf --- /dev/null +++ b/src/position/calculateComponentPositionAndSize.ts @@ -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); +} diff --git a/src/position/is-string.ts b/src/position/is-string.ts new file mode 100644 index 0000000..6b68c17 --- /dev/null +++ b/src/position/is-string.ts @@ -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; +}; diff --git a/src/position/on-resize.ts b/src/position/on-resize.ts new file mode 100644 index 0000000..3ff3787 --- /dev/null +++ b/src/position/on-resize.ts @@ -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; + } +} diff --git a/src/position/translate.ts b/src/position/translate.ts new file mode 100644 index 0000000..43c3e57 --- /dev/null +++ b/src/position/translate.ts @@ -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 + '%'; +} diff --git a/src/position/type.ts b/src/position/type.ts new file mode 100644 index 0000000..bc42ac2 --- /dev/null +++ b/src/position/type.ts @@ -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'; diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..f2fe538 --- /dev/null +++ b/src/render.ts @@ -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 = { + 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 = (ctx: RenderContext) => Promise; +type Unmount = (ctx?: RenderContext) => Promise; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..20efd0c --- /dev/null +++ b/src/store/index.ts @@ -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; + updateData: (newData: RenderData[]) => Promise; + updateDataCode: (newData: RenderData[]) => Promise; + loadData: (data: RenderData[]) => Promise; +}; +export const createContainerStore = () => { + const containerStore = createStore((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; +}; diff --git a/src/utils/el.ts b/src/utils/el.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/generate.ts b/src/utils/generate.ts new file mode 100644 index 0000000..c66c81f --- /dev/null +++ b/src/utils/generate.ts @@ -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); +}; diff --git a/src/utils/get-tree.ts b/src/utils/get-tree.ts new file mode 100644 index 0000000..c06e717 --- /dev/null +++ b/src/utils/get-tree.ts @@ -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, + }; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..92fb8ba --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} \ No newline at end of file