init container
This commit is contained in:
		
							
								
								
									
										58
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								package.json
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										10
									
								
								readme.md
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										46
									
								
								rollup.config.js
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										74
									
								
								src/container-edit.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										354
									
								
								src/container-store.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										20
									
								
								src/container.css
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										416
									
								
								src/container.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										11
									
								
								src/edit.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										63
									
								
								src/event/continer.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										3
									
								
								src/event/emitter.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| import EventEmitter from 'eventemitter3'; | ||||
|  | ||||
| export const emitter = new EventEmitter(); | ||||
							
								
								
									
										35
									
								
								src/event/unload.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/event/unload.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										9
									
								
								src/index.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										295
									
								
								src/listener/dom.ts
									
									
									
									
									
										Normal 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'; | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										288
									
								
								src/position/calculateComponentPositionAndSize.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										288
									
								
								src/position/calculateComponentPositionAndSize.ts
									
									
									
									
									
										Normal 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); | ||||
| } | ||||
							
								
								
									
										7
									
								
								src/position/is-string.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/position/is-string.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										95
									
								
								src/position/on-resize.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										146
									
								
								src/position/translate.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										25
									
								
								src/position/type.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										24
									
								
								src/render.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										86
									
								
								src/store/index.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										0
									
								
								src/utils/el.ts
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										9
									
								
								src/utils/generate.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/utils/generate.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										14
									
								
								src/utils/get-tree.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										23
									
								
								tsconfig.json
									
									
									
									
									
										Normal 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" | ||||
|   ] | ||||
| } | ||||
		Reference in New Issue
	
	Block a user