From 5d7ec91fa7d5317fb2373b2e6d452c7815a32a4c Mon Sep 17 00:00:00 2001 From: luojiahao Date: Fri, 26 Aug 2022 16:47:53 +0800 Subject: [PATCH] TASK: #103598 - add container --- CHANGELOG.md | 3 +- src/components/grid-container/container.ts | 1915 +++++++++++++++++ .../grid-container/contianer-interface.ts | 132 ++ .../grid-container/gaia-container-child.ts | 295 +++ .../grid-container/gaia-container-folder.ts | 553 +++++ .../grid-container/gaia-container-page.ts | 116 + .../grid-container/gesture-manager.ts | 69 + .../panels/container/homescreen-container.ts | 61 + src/test/panels/container/icon-style.ts | 75 + src/test/panels/container/icon.ts | 54 + src/test/panels/root.ts | 9 + 11 files changed, 3281 insertions(+), 1 deletion(-) create mode 100644 src/components/grid-container/container.ts create mode 100644 src/components/grid-container/contianer-interface.ts create mode 100644 src/components/grid-container/gaia-container-child.ts create mode 100644 src/components/grid-container/gaia-container-folder.ts create mode 100644 src/components/grid-container/gaia-container-page.ts create mode 100644 src/components/grid-container/gesture-manager.ts create mode 100644 src/test/panels/container/homescreen-container.ts create mode 100644 src/test/panels/container/icon-style.ts create mode 100644 src/test/panels/container/icon.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea08370..08be918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,5 @@ - add button - add bubble - add indicator-page-point -- add blur \ No newline at end of file +- add blur +- add contaienr diff --git a/src/components/grid-container/container.ts b/src/components/grid-container/container.ts new file mode 100644 index 0000000..996310b --- /dev/null +++ b/src/components/grid-container/container.ts @@ -0,0 +1,1915 @@ +import {html, css, LitElement} from 'lit' +import {customElement, property, state} from 'lit/decorators.js' +import GaiaContainerChild from './gaia-container-child' +import GestureManager from './gesture-manager' +import GaiaContainerPage from './gaia-container-page' +import GaiaContainerFolder from './gaia-container-folder' +import {DragAndDrop, STATUS, ChildElementInfo} from './contianer-interface' +/** + * 想法: + * 1. 用 grid 布局的特性排列应用图标(1×1)和小组件(n×m) + * 2. 用 row、column 属性控制布局 + * 3. 记录初始化后、布局变化后的高度属性,之后插入的图标和小组件进行越界判断,越界则尝试 + * 放入下一个页面中,直至不越界 + * 4. 翻页手势判断需要考量两个方面:触控结束时,划动距离/划动速度 + * 5. 跨页移动图标/组件要考虑页面容量,容量不够则跨页移动失败,需要将被移动元素放回原位 + * 6. 组件有 static dragging sorting turning swipping open_folder 状态, + * 分别对应静止、拖动、整理、翻页、划动、开启文件夹状态,状态发生变化时,将会发出 + * statuschange 事件 + * 7. 因为图标、组件换位需要消耗资源,采用如下策略: + * a. 拖拽元素越过可交换元素两侧时,可以尝试向前(/后)插入,并做越界判断 + * b. 拖拽元素尝试插入空间不足的页面时,记录此次拖拽期间无法向该页面插入拖拽元素 + * c. 拖拽元素越过不可交换元素两侧时,记录该元素的 element 为不可交换元素 + * d. 拖拽移动时,通过手指落点判断当前落点元素,在记录中查找是否为不可交换元素或不可插入页面, + * 是则跳过 【插入 -> 同步图标位置 -> 判断是否越界 -> 越界处理及记录不可交换元素】 这一 + * 系列步骤,否则执行步骤 + * 8. 合并文件夹,当有图标悬浮到另一个图标正上方一段时间时,处于下方的图标会合并为一个文件夹 + */ + +/** + * The time, in ms, to wait for an animation to start in response to a state + * change, before removing the associated style class. + */ +const STATE_CHANGE_TIMEOUT = 100 + +/** + * The time, in ms, to wait before initiating a drag-and-drop from a + * long-press. + */ +const DEFAULT_DND_TIMEOUT = 300 +/** + * The distance, in CSS pixels, in which a touch or mouse point can deviate + * before it being discarded for initiation of a drag-and-drop action. + */ +const DND_THRESHOLD = 5 + +/** + * 图标重叠时,确认要合并为或并入一个文件夹的时间 + */ +const MERGE_FORDER_TIME = 700 + +/** + * 与文件夹交互的计时器时长 + */ +const FORDER_OPERATE_TIME = 500 + +/** + * The minimum time between sending move events during drag-and-drop. + */ +const DND_MOVE_THROTTLE = 50 + +/** + * bezier + */ + +const cubic_bezier = ( + p0: number, + p1: number, + p2: number, + p3: number, + t: number +): number => { + return ( + p0 * Math.pow(1 - t, 3) + + 3 * p1 * t * Math.pow(1 - t, 2) + + 3 * p2 * t * t * (1 - t) + + p3 * Math.pow(t, 3) + ) +} + +@customElement('star-container') +class GaiaContainer extends LitElement { + name: string = 'star-container' + row: number = 6 + column: number = 4 + _frozen: boolean = false + _pendingStateChanges: Function[] = [] + _children: GaiaContainerChild[] = [] + _dnd: DragAndDrop = { + // Whether drag-and-drop is enabled + enabled: false, + + // The time, in ms, to wait before initiating a drag-and-drop from a + // long-press + delay: DEFAULT_DND_TIMEOUT, + + // Timeout used to initiate drag-and-drop actions + timeout: undefined, + + // The child that was tapped/clicked + child: GaiaContainerChild, + + // Whether a drag is active + active: false, + + // The start point of the drag action + start: { + pageX: 0, + pageY: 0, + clientX: 0, + clientY: 0, + translateX: 0, + translateY: 0, + }, + + // The last point of the drag action + last: { + pageX: 0, + pageY: 0, + clientX: 0, + clientY: 0, + timeStamp: 0, + offsetHeight: 0, + offsetWidth: 0, + offsetX: 0, + offsetY: 0, + }, + + // Timeout used to send drag-move events + moveTimeout: undefined, + + // The last time a move event was fired + lastMoveEventTime: 0, + + // Whether to capture the next click event + clickCapture: false, + + // 下一个兄弟节点,用于跨页移动失败后返回原位置时定位用 + nextSibling: null, + previousSibling: null, + + // 是否正在跨页 + isSpanning: false, + + // 正被悬浮覆盖的元素 + dropTarget: null, + + // 最后一个悬浮经过的元素 + lastDropChild: null, + + pagination: 0, + top: 0, + left: 0, + } + // 当前所显示的页面页码 + pagination: number = 0 + // 组件高度,也是页面高度,用于子组件越界判断 + height: number = 0 + width: number = 0 + // 页面列表 + pages = new GaiaContainerPage(this) as unknown as HTMLElement[] + // 滑动偏移量 + _offsetX: number = 0 + // 本次触摸滑动距离 + distance: number = 0 + // 是否进入整理模式 + _sortMode: boolean = false + // 状态 + _status: typeof STATUS[keyof typeof STATUS] = STATUS.STATIC + // 交换元素位置时,无法进行交换的元素 + _staticElements: HTMLElement[] = [] + // 用于首尾页划动的橡皮绳效果 + ratio: number = 1 + // 合并成文件夹计时器 + mergeTimer: number | undefined = undefined + // 文件夹 + folders: {[folderName: string]: GaiaContainerFolder} = {} + // 开启状态的文件夹 + openedFolder: GaiaContainerFolder | null = null + gesture: GestureManager | null = null + dndObserver: MutationObserver | null = null + pageHeight: number = 0 + pageWidth: number = 0 + gridHeight: number = 0 + gridWidth: number = 0 + istouching: boolean = false + + constructor(row = 6, column = 4) { + super() + this.row = row + this.column = column + // this.attachShadow({ mode: "open" }); + + // this.shadowRoot && (this.shadowRoot.innerHTML = this.template); + } + + firstUpdated() { + let dndObserverCallback = () => { + console.log(this._dnd.enabled, this.dragAndDrop) + if (this._dnd.enabled !== this.dragAndDrop) { + this._dnd.enabled = this.dragAndDrop + if (this._dnd.enabled) { + this.addEventListener('touchstart', this) + this.addEventListener('touchmove', this) + this.addEventListener('touchcancel', this) + this.addEventListener('touchend', this) + // 暂不考虑mouse事件,因其与touch事件的触发顺序会影响翻页动画: + // touchend 触发 resetView, mousedown 随后就打断 + // this.addEventListener("mousedown", this); + // this.addEventListener("mousemove", this); + // this.addEventListener("mouseup", this); + this.addEventListener('click', this, true) + this.addEventListener('contextmenu', this, true) + + this.addEventListener('swipeleft', this.swipe) + this.addEventListener('swiperight', this.swipe) + // this.gestureDetector.on('swipeleft', this.swipe); + // this.gestureDetector.on('swiperight', this.swipe); + // this.gestureDetector.on('doubleSwipe', this.swipe); + + this.addEventListener('folder-destroy', this.destroyFolder) + // 多指划屏 + this.gesture = new GestureManager(this) + } else { + this.cancelDrag() + this.removeEventListener('touchstart', this) + this.removeEventListener('touchmove', this) + this.removeEventListener('touchcancel', this) + this.removeEventListener('touchend', this) + // this.removeEventListener("mousedown", this); + // this.removeEventListener("mousemove", this); + // this.removeEventListener("mouseup", this); + this.removeEventListener('click', this, true) + this.removeEventListener('contextmenu', this, true) + + this.removeEventListener('swipeleft', this.swipe) + this.removeEventListener('swiperight', this.swipe) + // this.gestureDetector.off('swipeleft', this.swipe); + // this.gestureDetector.off('swiperight', this.swipe); + // this.gestureDetector.off('doubleSwipe', this.swipe); + this.removeEventListener('folder-destroy', this.destroyFolder) + } + } + } + this.dndObserver = new MutationObserver(dndObserverCallback) + this.dndObserver.observe(this, { + attributes: true, + attributeFilter: ['drag-and-drop'], + }) + + dndObserverCallback() + this.changeLayout() + } + + _getStatus(status = this._status) { + if (status === 0) return ['static'] + + return Object.keys(STATUS) + .filter((type) => { + return status & STATUS[type as keyof typeof STATUS] + }) + .map((type) => type.toLowerCase()) + } + + get status() { + return this._status + } + + set status(value) { + if (value !== this._status) { + this.dispatchEvent( + new CustomEvent('statuschange', { + detail: { + preStatus: this._getStatus(this._status), + curStatus: this._getStatus(value), + changeStatus: this._getStatus(this._status ^ value)[0], + }, + }) + ) + this._status = value + } + } + + swipe = (event: Event) => { + event.type === 'swipeleft' ? this.turnNext('swipe') : this.turnPre('swipe') + } + + turnPre(type: string) { + if (this.pagination <= 0) { + this.pagination = 0 + } else { + this.status |= STATUS.TURN + this.pagination-- + } + this.resetView(type) + } + + turnNext(type: string) { + if (this.pagination < this.pages.length - 1) { + this.pagination++ + this.status |= STATUS.TURN + } else { + this.pagination = this.pages.length - 1 + } + this.resetView(type) + } + + resetView(type: string) { + const target: HTMLElement = (this.pages as any)[this.pagination] + this.smoothSlide(target, type) + } + + // 同时触发划动时,不同触发原因的优先级顺序,越后越优先 + typeIndex = ['reset', 'swipe', 'mouseup', 'touchend'] + // 划动计时器 + timer: number | undefined = undefined + // 触发划动的原因类型 + slideType: string = '' + + smoothSlide(element: HTMLElement, type: string) { + if ( + !element || + this.typeIndex.indexOf(this.slideType) > this.typeIndex.indexOf(type) + ) + return + if (this.timer) { + clearInterval(this.timer) + this.slideType = '' + } + const animateTime = 450 + const target = -element.offsetLeft + const origin = this.offsetX + // 移动总距离 + const distance = target - origin + const startTime = new Date().getTime() + this.slideType = type + this.timer = setInterval(() => { + requestAnimationFrame(() => { + const cur = new Date().getTime() + const t = (cur - startTime) / animateTime + const ratio = cubic_bezier(0.2, 1, 0.95, 1, t) + this.offsetX = + origin + parseInt((distance * ratio) as unknown as string) + + // 在切换页面的时候,由以下代码控制拖拽元素的位置 + if (this._status & STATUS.DRAG) { + this.status |= STATUS.SWIPE + ;( + this._dnd.child as unknown as GaiaContainerChild + ).container.style.transform = + 'translate(' + + (this._dnd.left - this.offsetX) + + 'px, ' + + this._dnd.top + + 'px)' + // 控制与文件夹交互时的拖拽操作 + const child = this._dnd.child as unknown as GaiaContainerChild + if (child) { + child.container.style.setProperty( + '--offset-position-left', + this._dnd.left - this.offsetX + 'px' + ) + child.container.style.setProperty( + '--offset-position-top', + this._dnd.top + 'px' + ) + } + } + + if (ratio >= 1) { + clearInterval(this.timer) + this.timer = undefined + this.slideType = '' + this.offsetX = target + + // 矫正页码 + this.pagination = +(element.dataset.page || this.pagination) + // 清除翻页、划动状态 + this.status &= ~STATUS.TURN + this.status &= ~STATUS.SWIPE + } + }) + }, 10) + } + + destroyFolder = (evt: Event) => { + evt.stopImmediatePropagation() + evt.preventDefault() + + const folder: GaiaContainerFolder = (evt as CustomEvent).detail + this._children = this._children.filter((child) => child !== folder) + folder.master.remove() + delete this.folders[folder.name] + } + + get styleDom() { + return this.shadowRoot && this.shadowRoot.querySelector('style') + } + + get offsetX() { + return this._offsetX + } + + set offsetX(value) { + this.style.transform = `translateX(${value}px)` + this._offsetX = value + } + + get sortMode() { + return this._sortMode + } + + set sortMode(value) { + if (value) { + this._dnd.delay = 0 + this.status |= STATUS.SORT + } else { + this._dnd.delay = DEFAULT_DND_TIMEOUT + this.status &= ~STATUS.SORT + } + this._sortMode = value + } + + // TODO:用于适应旋转屏幕时,改变行数与列数,需要配合row、column属性 + changeLayout() { + // this.styleDom.innerHTML = this.shadowStyle; + const {height, width} = this.getBoundingClientRect() + this.height = height + this.width = width + this.pageWidth = this.pages.length ? this.pages[0].offsetWidth : 0 + this.pageHeight = this.pages.length ? this.pages[0].offsetHeight : 0 + this.gridHeight = this.pageHeight / this.row + this.gridWidth = this.pageWidth / this.column + + this.style.setProperty('--page-width', this.pageWidth + 'px') + this.style.setProperty('--page-height', this.pageHeight + 'px') + this.style.setProperty('--grid-height', this.gridHeight + 'px') + this.style.setProperty('--grid-width', this.gridWidth + 'px') + + this._children.forEach((child) => child.changeSize()) + } + + get containerChildren(): HTMLElement[] { + //div.icon-container + let target: HTMLElement[] = [] + this._children.forEach((child) => { + if (child instanceof GaiaContainerFolder) { + target.push(...(child.children as HTMLElement[])) + } else { + target.push(child.element as HTMLElement) + } + }) + return target + } + + getElementInfo(element: HTMLElement) { + let child = this.getChildByElement(element) + + if (child) { + return { + pagination: child.pagination, + row: child.row, + column: child.column, + anchorCoordinate: child.anchorCoordinate, + folderName: child.folderName, + } + } else { + return null + } + } + + getChildByElement(element: HTMLElement) { + let children = [...this._children] + for (const child of children) { + if ( + child._element === element || + child.master === element || + child.container === element + ) { + return child + } + + if (child instanceof GaiaContainerFolder) { + children.push(...child._children) + } + } + + return null + } + + get firstChild() { + return this._children.length ? this._children[0].element : null + } + + get lastChild() { + const length = this._children.length + return length ? this._children[length - 1].element : null + } + + @property({type: Boolean}) dragAndDrop!: boolean + + get dragAndDropTimeout() { + return this._dnd.delay + } + + set dragAndDropTimeout(timeout) { + if (timeout >= 0) { + this._dnd.delay = timeout + } else { + this._dnd.delay = DEFAULT_DND_TIMEOUT + } + } + + /** + * 在指定页面尾部添加元素,如果页面容量不够,则向后一页尾部添加 + * @param {Number} pagination 添加到的页码 + * @param {...any} args 被添加的元素 + * @returns 被添加的元素 + */ + realAppendChild(pagination = 0, ...args: HTMLElement[]) { + let page = this.pages[pagination] + if (!page) { + page = this.addPage() + } + let proto = HTMLElement.prototype.appendChild + let func + Array.prototype.forEach.call(args, (element) => { + func = proto.call(page, element) + + // 判断插入节点后是否会越界 + if (page.offsetHeight < page.scrollHeight) { + // 越界后判断是否需要重新拿出来放入下一个页面 + if (this._status & STATUS.DRAG) { + // 如果是拖拽中的元素,则返回原位,同时记录本页面无法插入 + if (!this._staticElements.includes(page)) { + this._staticElements.push(page) + } + if (this._dnd.nextSibling) { + this.realInsertBefore(args[0], this._dnd.nextSibling) + } else if (this._dnd.previousSibling) { + this.realInsertAfter(args[0], this._dnd.previousSibling) + } + } else { + // 不是拖拽中的元素,放入下一页 + this.realRemoveChild(element) + this.realAppendChild(++pagination, element) + } + } + }) + return func + } + + /** + * 移除组件内部的指定元素 + * @returns + */ + realRemoveChild(...args: HTMLElement[]) { + let func + Array.from(args).forEach((element) => { + const target = element.parentNode + + let proto = HTMLElement.prototype.removeChild + func = proto.call(target, element) + }) + return func + } + + /** + * 向指定元素前插入节点,若页面容量不够,还原节点位置或者向后一页尾部添加 + * @returns + */ + realInsertBefore(...args: HTMLElement[]) { + const targetParent = arguments[1].parentElement + const nodes = args as unknown as [HTMLElement, HTMLElement] + let proto = HTMLElement.prototype.insertBefore + let func = proto.apply(targetParent, nodes) + // 判断插入节点后是否会越界 + if (targetParent.offsetHeight < targetParent.scrollHeight) { + this.insertCrossBorder(...nodes) + } + return func + } + + /** + * 向指定元素后插入节点 + * @returns + */ + realInsertAfter(...args: [HTMLElement, HTMLElement]) { + const insert = HTMLElement.prototype.insertBefore + const append = HTMLElement.prototype.appendChild + const targetParent = args[1].parentElement + if (!targetParent) throw new Error('parentElement does not exist!') + let func + if (targetParent.lastChild == args[1]) { + func = append.call(targetParent, arguments[0]) + } else { + func = insert.call(targetParent, arguments[0], arguments[1].nextSibling) + } + + // 判断插入节点后是否会越界 + if (targetParent.offsetHeight < targetParent.scrollHeight) { + this.insertCrossBorder(...arguments) + } + + return func + } + + insertCrossBorder(...args: HTMLElement[]) { + if (!args[1] || !args[1].parentElement) { + throw new Error('gaia-container-page does not exist!') + } + let pagination = args[1].parentElement.dataset.page + // 越界后重新拿出来放入下一个页面 + if (!this._dnd.child && pagination) { + // 非拖拽元素导致的越界,后插元素直接加入下一个页面 + return this.realAppendChild(+pagination + 1, arguments[0]) + } + + // 同时记录被后插元素为无法交换元素 + // this._staticElements.push(args[1]); + // 同时记录当前页为无法插入页 + if (!this._staticElements.includes(args[1].parentElement)) { + this._staticElements.push(args[1].parentElement) + } + if (this._dnd?.child?.master === args[0] && this._dnd.isSpanning) { + if (this._dnd.nextSibling) { + // 当前图标/小组件跨页移动失败,根据后一个兄弟节点返回原位置 + return this.realInsertBefore(args[0], this._dnd.nextSibling) + } else if (this._dnd.previousSibling) { + // 当前图标/小组件跨页移动失败,根据前一个兄弟节点返回原位置 + return this.realInsertAfter(args[0], this._dnd.previousSibling) + } else if (this._dnd.pagination) { + // 无前后兄弟节点,退回源页面 + return this.realAppendChild(this._dnd.pagination, args[0]) + } + } + + return this.realAppendChild(this.pagination + 1, args[0]) + } + + realReplaceChild(...args: [HTMLElement, HTMLElement]) { + let proto = HTMLElement.prototype.replaceChild + let func = proto.apply(this, args) + return func + } + + /** + * + * @param {HTMLElement} element 向该组件添加的图标/小组件 + * @param {Object} options 所添加元素的尺寸信息和添加回调 + */ + appendContainerChild(element: HTMLElement, options?: ChildElementInfo) { + if (element.tagName === 'GAIA-WIDGET') { + let obj: ChildElementInfo = options + ? {...options} + : ({} as ChildElementInfo) + obj.row = (element as any).size[0] + obj.column = (element as any).size[1] + this.insertContainerBefore(element, null, obj) + } else { + this.insertContainerBefore(element, null, options) + } + } + + removeContainerChild(element: HTMLElement, callback: Function) { + let children = this._children + let childToRemove: GaiaContainerChild | null = null + + for (let child of children) { + if (child.element === element) { + childToRemove = child + break + } + } + + if (childToRemove === null) { + throw 'removeChild called on unknown child' + } + let that = this + this.changeState(childToRemove, 'removed', () => { + // that.realRemoveChild(childToRemove.container); + + childToRemove && that.realRemoveChild(childToRemove.master) + + // Find the child again. We need to do this in case the container was + // manipulated between removing the child and this callback being reached. + for (let i = 0, iLen = children.length; i < iLen; i++) { + if (children[i] === childToRemove) { + children.splice(i, 1) + break + } + } + + if (callback) { + callback() + } + + this.synchronise() + }) + } + + replaceContainerChild( + newElement: HTMLElement, + oldElement: HTMLElement, + callback: Function + ) { + if (!newElement || !oldElement) { + throw 'replaceChild called with null arguments' + } + + const info = this.getElementInfo(oldElement) + if (!info) throw Error('Can not get oldElement info!') + // Unparent the newElement if necessary (with support for gaia-container) + if (newElement.parentNode == this) { + this.removeContainerChild(newElement, () => { + this.replaceContainerChild(newElement, oldElement, callback) + }) + if (newElement.parentNode) { + return + } + } + + // Remove the old child and add the new one, but don't run the removed/ + // added state changes. + let children = this._children + + for (let i = 0, iLen = children.length; i < iLen; i++) { + let oldChild = children[i] + if (oldChild.element === oldElement) { + const {row, column, anchorCoordinate} = info + let newChild = new GaiaContainerChild( + newElement, + row, + column, + anchorCoordinate, + this + ) + // this.realInsertBefore(newChild.container, oldChild.container); + this.realInsertBefore(newChild.master, oldChild.master) + // this.realRemoveChild(oldChild.container); + this.realRemoveChild(oldChild.master) + this._children.splice(i, 1, newChild) + this.synchronise() + + if (callback) { + callback() + } + return + } + } + + throw 'removeChild called on unknown child' + } + + addPage() { + const page = (this.pages as any).addPage() + HTMLElement.prototype.appendChild.call(this, page) + + if (!this.pageHeight || !this.pageWidth) { + this.pageWidth = page.offsetWidth + this.pageHeight = page.offsetHeight + this.gridHeight = this.pageHeight / this.row + this.gridWidth = this.pageWidth / this.column + + this.style.setProperty('--page-width', this.pageWidth + 'px') + this.style.setProperty('--page-height', this.pageHeight + 'px') + this.style.setProperty('--grid-height', this.gridHeight + 'px') + this.style.setProperty('--grid-width', this.gridWidth + 'px') + } + return page + } + + /** + * Reorders the given element to appear before referenceElement. + */ + reorderChild( + element: HTMLElement, + referenceElement: HTMLElement, + callback?: Function + ) { + if (!element) { + throw 'reorderChild called with null element' + } + + let children = this._children + let child: GaiaContainerChild | null = null + let childIndex: number | null = null + let referenceChild: GaiaContainerChild | null = null + let referenceChildIndex = null + + for (let i = 0, iLen = children.length; i < iLen; i++) { + if (children[i].element === element) { + child = children[i] + childIndex = i + } else if (children[i].element === referenceElement) { + referenceChild = children[i] + referenceChildIndex = i + } + + if (child && (referenceChild || !referenceElement)) { + children.splice(childIndex as number, 1) + + if (referenceChild) { + this.realInsertBefore(child.master, referenceChild.master) + } else { + this.realAppendChild(child.pagination, child.master) + } + + referenceChild + ? children.splice( + (referenceChildIndex as number) - + ((childIndex as number) < (referenceChildIndex as number) + ? 1 + : 0), + 0, + child + ) + : children.splice(children.length, 0, child) + + this.synchronise() + + if (callback) { + callback() + } + return + } + } + + throw child + ? 'reorderChild called on unknown reference element' + : 'reorderChild called on unknown child' + } + + insertContainerBefore( + element: HTMLElement, + reference: HTMLElement | null, + { + callback, + row, + column, + pagination, + anchorCoordinate, + folderName, + }: ChildElementInfo = {} as ChildElementInfo + ) { + let children = this._children + let childToInsert = new GaiaContainerChild( + element, + row, + column, + anchorCoordinate, + this + ) + let referenceIndex = -1 + + if (reference !== null) { + for ( + let i = 0, child = children[i], iLen = children.length; + i < iLen; + i++ + ) { + if (child.element === reference) { + referenceIndex = i + break + } + if (child instanceof GaiaContainerFolder) { + for (const element of child.children) { + if (element === reference) { + // reference 处于文件夹中,referenceIndex 指向该文件夹 + referenceIndex = i + break + } + } + } + } + if (referenceIndex === -1) { + throw 'insertBefore called on unknown child' + } + } + + if (folderName && this.folders[folderName]) { + // 属于文件夹内的图标 + children.push(childToInsert) + this.folders[folderName].addAppIcon(childToInsert.master) + } else if (referenceIndex === -1) { + this.realAppendChild(pagination ?? 0, childToInsert.master) + children.push(childToInsert) + } else { + const referenceNode = children[referenceIndex].folderName + ? this.folders[children[referenceIndex].folderName].master + : children[referenceIndex].master + this.realInsertBefore(childToInsert.master, referenceNode) + children.splice(referenceIndex, 0, childToInsert) + } + + if (folderName && !this.folders[folderName]) { + // 有文件夹名但不存在该文件夹,则将该图标转化为文件夹 + this.appToFolder(childToInsert.master) + } + + childToInsert.isWidget && (childToInsert.isStatic = true) + + this.changeState(childToInsert, 'added', callback) + this.synchronise() + } + + /** + * Used to execute a state-change of a child that may possibly be animated. + * @state will be added to the child's class-list. If an animation starts that + * has the same name, that animation will complete and @callback will be + * called. Otherwise, the class will be removed and @callback called on the + * next frame. + */ + changeState( + child: GaiaContainerChild, + state: 'added' | 'removed', + callback: Function | undefined + ) { + // Check that the child is still attached to this parent (can happen if + // the child is removed while frozen). + if ((child.master.parentElement as HTMLElement).parentNode !== this) { + return + } + + // Check for a redundant state change. + if (child.container.classList.contains(state)) { + return + } + + // Queue up state change if we're frozen. + if (this._frozen) { + this._pendingStateChanges.push( + this.changeState.bind(this, child, state, callback) + ) + return + } + + let animStart = (e: AnimationEvent) => { + if (!e.animationName.endsWith(state)) { + return + } + + child.container.removeEventListener('animationstart', animStart) + + window.clearTimeout(child[state]) + delete child[state] + + // let self = this; + child.container.addEventListener('animationend', function animEnd() { + child.container.removeEventListener('animationend', animEnd) + child.container.classList.remove(state) + if (callback) { + callback() + } + }) + } + + child.container.addEventListener('animationstart', animStart) + child.container.classList.add(state) + + child[state] = window.setTimeout(() => { + delete child[state] + child.container.removeEventListener('animationstart', animStart) + child.container.classList.remove(state) + if (callback) { + callback() + } + }, STATE_CHANGE_TIMEOUT) + } + + getChildOffsetRect(element: HTMLElement) { + let children = this._children + for (let i = 0, iLen = children.length; i < iLen; i++) { + let child = children[i] + if (child.element === element) { + let top = child._lastMasterTop as number + let left = child._lastMasterLeft as number + let width = child._lastElementWidth as number + let height = child._lastElementHeight as number + + return { + top: top, + left: left, + width: width, + height: height, + right: left + width, + bottom: top + height, + } + } + } + + throw 'getChildOffsetRect called on unknown child' + } + + getChildFromPoint(x: number, y: number) { + // 是否与主屏文件夹进行交互中 + const interactWithFolder = !!this.openedFolder + const {offsetHeight, offsetWidth, offsetX, offsetY} = this._dnd.last + let children + let folderPosition = {left: 0, top: 0, height: 0, width: 0} + let page = this.pages[this.pagination] + + if (interactWithFolder && this.openedFolder) { + children = this.openedFolder?.children + folderPosition.left = this.openedFolder.element.offsetLeft + folderPosition.top = this.openedFolder.element.offsetTop + folderPosition.height = this.openedFolder.element.offsetHeight + folderPosition.width = this.openedFolder.element.offsetWidth + } else { + children = this._children + } + x += -page.scrollLeft + page.offsetLeft + y += -page.scrollTop + page.offsetTop + + const childX = x - folderPosition.left + const childY = y - folderPosition.top + + if ( + childX >= offsetX && + childY >= offsetY && + childX < offsetX + offsetWidth && + childY < offsetY + offsetHeight + ) { + // 悬浮在自身容器之上 + this._staticElements = [this._dnd.child.element] + return {dropTarget: this._dnd.child.element, isPage: false} + } + + for (let i = 0, iLen = children.length; i < iLen; i++) { + let child = children[i] as GaiaContainerChild + if ( + childX >= (child._lastMasterLeft as number) && + childY >= (child._lastMasterTop as number) && + childX < + (child._lastMasterLeft as number) + + (child._lastElementWidth as number) && + childY < + (child._lastMasterTop as number) + + (child._lastElementHeight as number) + ) { + if (child.pagination !== this._dnd.child.pagination) { + // 当被选中元素与被移动元素页码不一致时,该次移动属于跨页移动 + this._dnd.isSpanning = true + } + return { + dropTarget: child.element, + isPage: false, + isFolder: child.isFolder, + } + } + } + + if ( + this.openedFolder && + x > folderPosition.left && + x < folderPosition.left + folderPosition.width && + y > folderPosition.top && + y < folderPosition.top + folderPosition.height + ) { + return { + dropTarget: this.openedFolder.element, + isPage: false, + isFolder: true, + } + } else if (this.openedFolder) { + return { + dropTarget: this.openedFolder.container, + isPage: false, + isFolder: true, + } + } + + let targetPage = null + let i = 0 + for (; i < this.pages.length; i++) { + const page = this.pages[i] + if (x > page.offsetLeft) { + targetPage = page + + if (page.dataset.page !== this._dnd.child.pagination) { + // 当目标页与被移动元素页码不一致时,该次移动属于跨页移动 + this._dnd.isSpanning = true + } + } else { + break + } + } + + return { + dropTarget: targetPage, + isPage: true, + pagination: --i, + } + } + + cancelDrag() { + if (this._dnd.timeout !== null) { + clearTimeout(this._dnd.timeout) + this._dnd.timeout = undefined + } + + if (this._dnd.moveTimeout !== null) { + clearTimeout(this._dnd.moveTimeout) + this._dnd.moveTimeout = undefined + } + + if (this._dnd.active) { + this._dnd.child.container.classList.remove('dragging') + this._dnd.child.container.style.position = 'absolute' + this._dnd.child.container.style.top = '0' + this._dnd.child.container.style.left = '0' + this._dnd.child.markDirty() + this._dnd.active = false + this._dnd.clickCapture = true + this.dispatchEvent(new CustomEvent('drag-finish')) + } + this.synchronise() + + if (this._dnd.lastDropChild) { + this._dnd.lastDropChild.master.classList.remove('merging') + this._dnd.lastDropChild = null + } + + this._dnd.child?.container.style.setProperty( + '--offset-position-left', + '0px' + ) + this._dnd.child?.container.style.setProperty('--offset-position-top', '0px') + this._dnd.child = null + this._dnd.isSpanning = false + this.status &= ~STATUS.DRAG + } + + startDrag() { + this.status |= STATUS.DRAG + this._staticElements = [this._dnd.child.element] + this._dnd.start.translateX = this._dnd.child.master.offsetLeft + this._dnd.start.translateY = this._dnd.child.master.offsetTop + + this._dnd.last.offsetHeight = this._dnd.child.master.offsetHeight + this._dnd.last.offsetWidth = this._dnd.child.master.offsetWidth + this._dnd.last.offsetX = this._dnd.child._lastMasterLeft + this._dnd.last.offsetY = this._dnd.child._lastMasterTop + if ( + !this.dispatchEvent( + new CustomEvent('drag-start', { + cancelable: true, + detail: { + target: this._dnd.child.element, + pageX: this._dnd.start.pageX, + pageY: this._dnd.start.pageY, + clientX: this._dnd.start.clientX, + clientY: this._dnd.start.clientY, + }, + }) + ) + ) { + return + } + + this._dnd.active = true + this._dnd.child.container.classList.add('dragging') + this._dnd.child.isStatic = false + // this._dnd.child.container.style.position = "fixed"; + let rect = this.getBoundingClientRect() + this._dnd.child.container.style.top = rect.top + 'px' + this._dnd.child.container.style.left = rect.left - this.offsetX + 'px' + this._dnd.pagination = this.pagination + } + + continueDrag() { + if (!this._dnd.active) { + return + } + + let left = + (this._dnd.start.translateX as number) + + (this._dnd.last.pageX - this._dnd.start.pageX) - + this.pages[this._dnd.pagination].offsetLeft + let top = + (this._dnd.start.translateY as number) + + (this._dnd.last.pageY - this._dnd.start.pageY) + this._dnd.left = left + this._dnd.top = top + + // 在切换页面的时候,不由以下代码控制拖拽元素的位置 + if (!this.timer) { + this._dnd.child.container.style.transform = + 'translate(' + (this._dnd.left - this.offsetX) + 'px, ' + top + 'px)' + // 控制与文件夹交互时的拖拽操作 + this._dnd.child?.container.style.setProperty( + '--offset-position-left', + this._dnd.last.pageX - this._dnd.start.pageX + 'px' + ) + this._dnd.child?.container.style.setProperty( + '--offset-position-top', + this._dnd.last.pageY - this._dnd.start.pageY + 'px' + ) + } + + if (this._dnd.moveTimeout === undefined) { + let delay = Math.max( + 0, + DND_MOVE_THROTTLE - + (this._dnd.last.timeStamp - this._dnd.lastMoveEventTime) + ) + this._dnd.moveTimeout = setTimeout(() => { + this._dnd.moveTimeout = undefined + this._dnd.lastMoveEventTime = this._dnd.last.timeStamp + this.dispatchEvent( + new CustomEvent('drag-move', { + detail: { + target: this._dnd.child.element, + pageX: this._dnd.last.pageX, + pageY: this._dnd.last.pageY, + clientX: this._dnd.last.clientX, + clientY: this._dnd.last.clientY, + }, + }) + ) + }, delay) + } + + this.dropElement() + } + + clearMergeTimer() { + clearTimeout(this.mergeTimer) + this.mergeTimer = undefined + } + + /** + * 交换被拖拽元素位置 + */ + dropElement() { + let {dropTarget, isPage, pagination} = this.getChildFromPoint( + this._dnd.last.clientX, + this._dnd.last.clientY + ) + this._dnd.dropTarget = dropTarget + // 拖拽元素悬浮页面默认为当前页面 + const suspendingPage = isPage ? dropTarget : this.pages[this.pagination] + if ( + dropTarget === this._dnd.child.element || // 悬浮在自身容器之上 + (this._dnd.child.isTail && isPage) + ) { + // 拖拽元素为页面尾部元素,且悬浮在页面空白处 + if (this._dnd.lastDropChild) { + this._dnd.lastDropChild.master.classList.remove('merging') + this._dnd.lastDropChild = null + } + + this.clearMergeTimer() + return + } + if (dropTarget?.dataset?.static) { + // 在尝试与锚固元素交换 + if (dropTarget.tagName === 'GAIA-WIDGET') { + // 锚固元素为主屏小组件 + if (this._dnd.child.isWidget) { + const widget = this.getChildByElement( + dropTarget + ) as GaiaContainerChild + // 交换元素也为小组件时,解除锚固并进行交换 + widget.isStatic = false + } else { + // 交换元素为普通的图标,无法交换 + return this._staticElements.push(dropTarget) + } + } + } + + let children = this._children + let folderPosition = {left: 0, top: 0} + if (this.openedFolder) { + children = this.openedFolder._children + folderPosition.left = this.openedFolder.element.offsetLeft + folderPosition.top = this.openedFolder.element.offsetTop + } + if (isPage) { + this._dnd.child.isWidget && (this._dnd.child.isStatic = false) + this.realAppendChild(pagination, this._dnd.child.master) + this._dnd.child.isWidget && (this._dnd.child.isStatic = 'current') + return this.synchronise() + } + if (dropTarget) { + let dropChild: GaiaContainerChild | null = null + let dropIndex = -1 + let childIndex = -1 + let operator = '' + const lastX = + this._dnd.last.pageX + this.pages[this.pagination].offsetLeft + const lastY = this._dnd.last.pageY + for (let i = 0, iLen = children.length; i < iLen; i++) { + if (children[i] === this._dnd.child) { + childIndex = i + } + + if (children[i].element === dropTarget) { + dropChild = children[i] + dropIndex = i + + if (this._dnd.lastDropChild !== dropChild) { + this._dnd.lastDropChild?.master.classList.remove('merging') + this.clearMergeTimer() + this._dnd.lastDropChild = dropChild + } + + if (this.openedFolder) { + if (dropTarget === this.openedFolder.element) { + // 添加进文件夹 + operator = 'addInFolder' + } else if (dropTarget === this.openedFolder.container) { + // 将要移出文件夹 + setTimeout(() => { + ;(this.openedFolder as GaiaContainerFolder).close() + operator = 'removeFromFolder' + }, FORDER_OPERATE_TIME) + } else { + // 与文件夹内其他图标交换 + } + } + + const containerX = + lastX - children[i].master.offsetLeft - folderPosition.left + const containerY = + lastY - children[i].master.offsetTop - folderPosition.top + const ratioX = containerX / children[i].master.offsetWidth + const ratioY = containerY / children[i].master.offsetHeight + + if (ratioX < 0.3 || (ratioX < 0.5 && ratioY < 0.15)) { + operator = 'insertBefore' + dropChild.master.classList.remove('merging') + this.clearMergeTimer() + } else if (ratioX > 0.7 || (ratioX >= 0.5 && ratioY < 0.15)) { + operator = 'insertAfter' + dropChild.master.classList.remove('merging') + this.clearMergeTimer() + } else if (ratioY >= 0.85) { + dropChild.master.classList.remove('merging') + this.clearMergeTimer() + } else { + operator = 'merge' + if ( + !this.mergeTimer && + dropChild.position === 'page' && + !dropChild.isFolder && + !this._dnd.child.isFolder && + !dropChild.isWidget && + !this._dnd.child.isWidget + ) { + // 图标悬浮于另一个图标正上方 + dropChild.master.classList.add('merging') + this.mergeTimer = setTimeout(() => { + this.mergeFolder( + (dropChild as GaiaContainerChild).master, + this._dnd.child.master + ) + this.clearMergeTimer() + }, MERGE_FORDER_TIME) + } else if ( + dropChild.position === 'page' && + dropChild.isFolder && + !this._dnd.child.isFolder && + !dropChild.isWidget && + !this._dnd.child.isWidget + ) { + // 图标悬浮于文件夹正上方 + this.clearMergeTimer() + dropChild.master.classList.add('merging') + this.mergeTimer = setTimeout(() => { + ;(dropChild as GaiaContainerFolder).open() + this.mergeFolder( + (dropChild as GaiaContainerFolder).master, + this._dnd.child.master + ) + this.clearMergeTimer() + }, MERGE_FORDER_TIME) + } + } + } + + if (dropIndex >= 0 && childIndex >= 0) { + if ( + (operator !== 'merge' && + this._staticElements.includes(dropTarget)) || + this._staticElements.includes(suspendingPage) + ) { + break + } + this.dropOperate(dropIndex, dropChild, childIndex, operator) + break + } + } + } + } + + /** + * 将 App 合并入落点所在的文件夹,若落点无文件夹,则由落点元素转化为文件夹 + * @param {HTMLElement} dropItem 落点元素 + * @param {HTMLElement} appMaster 悬浮应用图标 + */ + mergeFolder(dropItem: HTMLElement, appMaster: HTMLElement) { + if (!dropItem.classList.contains('folder')) { + this.appToFolder(dropItem) + } else { + const folder = this.getChildByElement(dropItem) as GaiaContainerFolder + folder.addAppIcon(appMaster, true) + } + } + + /** + * 将应用图标转化为文件夹 + * @param {HTMLElement} appMaster 将要被转化为文件夹的应用图标 + * @param {String | undefined} folderName 文件名 + */ + appToFolder(appMaster: GaiaContainerChild['master']) { + const folder = new GaiaContainerFolder(this) + const name = folder.name + this.folders[name] = folder + this._children.push(folder) + let referenceNode + const page = appMaster.parentElement + let appendFolder = (referenceNode: HTMLElement) => + HTMLElement.prototype.insertBefore.call( + page, + folder.master, + referenceNode + ) + if (appMaster.nextElementSibling) { + referenceNode = appMaster.nextElementSibling + } else { + if (appMaster.nextElementSibling) { + referenceNode = appMaster.nextElementSibling + } else { + appendFolder = () => + HTMLElement.prototype.appendChild.call(page, folder.master) + } + } + + folder.addAppIcon(appMaster) + appendFolder(referenceNode as HTMLElement) + + this._staticElements = this._staticElements.filter( + (el) => el !== folder.element + ) + // this.getChildByElement(folder.master).synchroniseContainer(); + // this.getChildByElement(appMaster).synchroniseContainer(); + // folder.open(); + return folder + } + + /** + * 拖拽操作,分为向前插入、向后插入、融合为文件夹 + * @param {Number} dropIndex 拖拽落点元素序号 + * @param {HTMLElement} dropChild 拖拽落点元素 + * @param {Number} childIndex 拖拽元素序号 + * @param {String} operator 拖拽操作 + */ + dropOperate( + dropIndex: number, + dropChild: GaiaContainerChild | null, + childIndex: number, + operator: string + ) { + // 被拖拽元素与放置位置元素为同一元素,取消接下来的插入操作 + // if (dropIndex === childIndex) return this.cancelDrag(); + const children = this._children + this._dnd.child.isWidget && (this._dnd.child.isStatic = false) + // Default action, rearrange the dragged child to before or after + // the child underneath the touch/mouse point. + // this.realRemoveChild(this._dnd.child.container); + // this.realRemoveChild(this._dnd.child.master); + // 此次拖拽操作不属于向前插入动作 + // if (!insertBefore && !dropChild.master.nextSibling) { + if (operator === 'insertAfter' && dropChild) { + this.realInsertAfter(this._dnd.child.master, dropChild.master) + this._staticElements.push(dropChild.element as HTMLElement) + } else if (operator === 'insertBefore' && dropChild) { + this.realInsertBefore(this._dnd.child.master, dropChild.master) + this._staticElements.push(dropChild.element as HTMLElement) + } else { + // TODO: 此处为合并为文件夹方法 + // this.mergeForder(); + } + children.splice(dropIndex, 0, children.splice(childIndex, 1)[0]) + this.dispatchEvent(new CustomEvent('drag-rearrange')) + this.synchronise() + } + + endDrag(event: Event) { + if (this._dnd.active) { + this.dropElement() + this.dispatchEvent( + new CustomEvent('drag-end', { + cancelable: true, + detail: { + target: this._dnd.child.element, + dropTarget: this._dnd.dropTarget, + pageX: this._dnd.last.pageX, + pageY: this._dnd.last.pageY, + clientX: this._dnd.last.clientX, + clientY: this._dnd.last.clientY, + }, + }) + ) + } else if (this._dnd.timeout !== null && this._dnd.child?.isFolder) { + this._dnd.child.open() + } else if (this._dnd.timeout !== null) { + let handled = !this.dispatchEvent( + new CustomEvent('activate', { + cancelable: true, + detail: { + target: this._dnd.child.element, + }, + }) + ) + if (handled) { + event.stopImmediatePropagation() + event.preventDefault() + } + } + + this.cancelDrag() + } + + handleTransformRatio(x: number) { + if (this._status & STATUS.DRAG) return x + + const percentage = Math.abs(x) / this.pageHeight + this.ratio = 1 + + if ( + (!+this.pagination && x >= 0) || + (this.pagination == this.pages.length - 1 && x < 0) + ) { + this.ratio = 1 / (4 * percentage + 1) + } + + return this.ratio > 0 ? this.ratio : 0 + } + + handleEvent(event: Event) { + switch (event.type) { + case 'touchstart': + case 'mousedown': + this.istouching = true + if (this.timer) { + clearInterval(this.timer) + this.timer = undefined + this.slideType = '' + } + if (this._dnd.active || this._dnd.timeout) { + // this.cancelDrag(); + + break + } + + if (event instanceof MouseEvent) { + this._dnd.start.pageX = event.pageX + this._dnd.start.pageY = event.pageY + this._dnd.start.clientX = event.clientX + this._dnd.start.clientY = event.clientY + } else if (event instanceof TouchEvent) { + this._dnd.start.pageX = event.touches[0].pageX + this._dnd.start.pageY = event.touches[0].pageY + this._dnd.start.clientX = event.touches[0].clientX + this._dnd.start.clientY = event.touches[0].clientY + } + + this._dnd.last.pageX = this._dnd.start.pageX + this._dnd.last.pageY = this._dnd.start.pageY + this._dnd.last.clientX = this._dnd.start.clientX + this._dnd.last.clientY = this._dnd.start.clientY + this._dnd.last.timeStamp = event.timeStamp + + let target = event.target as HTMLElement //gaia-app-icon + + // Find the child + let children = [...this._children] + for (let child of children) { + if (child.isFolder) { + children.push(...(child as GaiaContainerFolder)._children) + } + if ( + child.element === target || + child.master === target || + (!child.isFolder && + (child.element as HTMLElement).compareDocumentPosition(target) & + 16) + ) { + this._dnd.child = child + this._dnd.nextSibling = child.master.nextSibling as HTMLElement + this._dnd.previousSibling = child.master + .previousSibling as HTMLElement + break + } + } + + if (!this._dnd.child) { + return + } + + if (this._dnd.delay > 0) { + this._dnd.timeout = setTimeout(() => { + this._dnd.timeout = undefined + this.startDrag() + }, this._dnd.delay) + } else { + this.startDrag() + } + break + + case 'touchmove': + case 'mousemove': + let pageX, pageY, clientX, clientY + if (event instanceof MouseEvent) { + pageX = event.pageX + pageY = event.pageY + clientX = event.clientX + clientY = event.clientY + } else { + pageX = (event as TouchEvent).touches[0].pageX + pageY = (event as TouchEvent).touches[0].pageY + clientX = (event as TouchEvent).touches[0].clientX + clientY = (event as TouchEvent).touches[0].clientY + } + this.distance = pageX - this._dnd.last.pageX + + if (!(this._status & STATUS.DRAG)) { + const ratio = this.handleTransformRatio(this.distance) + this.istouching && + (this.status |= STATUS.SWIPE) && + (this.style.transform = `translateX(${ + parseInt(String(this.distance * ratio)) + this.offsetX + }px`) + } + + if (this._dnd.timeout) { + if ( + Math.abs(pageX - this._dnd.start.pageX) > DND_THRESHOLD || + Math.abs(pageY - this._dnd.start.pageY) > DND_THRESHOLD + ) { + clearTimeout(this._dnd.timeout) + this._dnd.timeout = undefined + } + } else if (this._dnd.active) { + event.preventDefault() + this._dnd.last.pageX = pageX + this._dnd.last.pageY = pageY + this._dnd.last.clientX = clientX + this._dnd.last.clientY = clientY + this._dnd.last.timeStamp = event.timeStamp + + this.continueDrag() + } + break + + case 'touchcancel': + this.istouching = false + this.cancelDrag() + break + + case 'touchend': + case 'mouseup': + this.istouching = false + if (!(this._status & STATUS.DRAG)) { + this.offsetX += +this.distance * this.ratio + this.ratio = 1 + if (Math.abs(this.distance) < this.width / 2) { + } else if (this.distance > 0) { + this.turnPre(event.type) + } else { + this.turnNext(event.type) + } + this.distance = 0 + } + this.resetView('') + + if (this._dnd.active) { + event.preventDefault() + // event.stopImmediatePropagation(); + } + + !(event as TouchEvent)?.touches?.length && this.endDrag(event) + break + + case 'click': + if (this._dnd.clickCapture) { + this._dnd.clickCapture = false + event.preventDefault() + event.stopImmediatePropagation() + } + + if (this._dnd?.child?.isFolder) { + event.preventDefault() + event.stopImmediatePropagation() + this._dnd.child.open() + this.cancelDrag() + } + break + + case 'contextmenu': + if (this._dnd.active || this._dnd.timeout) { + event.stopImmediatePropagation() + // event.preventDefault(); + } + break + } + } + + /** + * Temporarily disables element position synchronisation. Useful when adding + * multiple elements to the container at the same time, or in quick + * succession. + */ + freeze() { + this._frozen = true + } + + /** + * Enables element position synchronisation after freeze() has been called. + */ + thaw() { + if (this._frozen) { + this._frozen = false + for (let callback of this._pendingStateChanges) { + callback() + } + this._pendingStateChanges = [] + this.synchronise() + } + } + + /** + * Synchronise positions between the managed container and all children. + * This is called automatically when adding/inserting or removing children, + * but must be called manually if the managed container is manipulated + * outside of these methods (for example, if style properties change, or + * if it's resized). + */ + synchronise() { + if (this._frozen) { + return + } + + for (let child of this._children) { + if (child.isWidget) { + if (this._dnd.child === child) { + child.isStatic = 'current' + continue + } + child.isStatic = true + } + } + + let child + let children = [...this._children] + for (child of children) { + // if (!this._dnd.active || child !== this._dnd.child) { + child.synchroniseMaster() + // } + } + + for (let i = 0; i < children.length; i++) { + child = children[i] + if (child.isFolder) + children.splice(i + 1, 0, ...(child as GaiaContainerFolder)._children) + let isActive = this._dnd.active && child === this._dnd.child + child.synchroniseContainer(isActive) + + // 越界 + if (!isActive) { + if ( + child.position === 'page' && + this.pageHeight < + (child._lastMasterTop as number) + + (child._lastElementHeight as number) + ) { + // 页内越界 + this.realAppendChild(child.pagination + 1, child.master) + } else if ( + child.position === 'folder' && + this.folders[child.folderName].element.offsetHeight < + (child._lastMasterTop as number) + + (child._lastElementHeight as number) + ) { + // 文件夹内越界 + this.folders[child.folderName].element.appendChild(child.master) + } + } + + if (isActive) { + // 被拖拽元素更新容器位置后,还需要更新 _dnd.last + this._dnd.last.offsetHeight = child.master.offsetHeight + this._dnd.last.offsetWidth = child.master.offsetWidth + this._dnd.last.offsetX = child._lastMasterLeft as number + this._dnd.last.offsetY = child._lastMasterTop as number + + // 更新不可交换元素数组 + // this._staticElements = [child]; + } + } + } + + static styles = css` + :host { + position: relative; + display: block; + + width: 100%; + height: 100%; + } + :host #container { + width: 100%; + height: 100%; + display: grid; + /* 设置三列,每列33.33%宽,也可以用repeat重复 */ + /* grid-template-columns: 33.33% 33.33% 33.33%; */ + /* grid-template-columns: repeat(3, 33.33%); */ + grid-template-rows: repeat(1, 100%); + grid-template-columns: repeat(auto-fit, 100%); + + /* 间距,格式为 row column,单值时表示行列间距一致 */ + gap: 0px; + /* 排列格式,此处意为先列后行,不在意元素顺序,尽量塞满每列 */ + grid-auto-flow: column dense; + + /* 隐式网格宽度,即 grid-template-rows 属性规定列数外的网格列宽 */ + grid-auto-columns: 100vw; + } + + ::slotted(.gaia-container-page) { + display: grid; + grid-template-columns: repeat(4, 25%); + grid-template-rows: repeat(6, 16.66%); + grid-auto-flow: row dense; + + grid-auto-rows: 33%; + height: 100%; + transform: opacity 0.1s; + } + + ::slotted(.gaia-container-child.dragging) { + z-index: 1; + will-change: transform; + } + + ::slotted(.container-master) { + opacity: 1; + transform: opacity 0.15s; + } + ::slotted(.gaia-container-page) > .container-master.merging { + opacity: 0.5; + } + + ::slotted(.gaia-container-child) { + height: calc(var(--grid-height) * var(--rows, 1)); + width: calc(var(--grid-width) * var(--columns, 1)); + display: flex !important; + flex-direction: column; + align-items: center; + justify-content: center; + } + + ::slotted(.folder) > .gaia-container-child { + transition: transform 0.2s, height 0.2s, width 0.2s; + } + ` + + @state() + shadowStyles: {[subModule: string]: string} = new Proxy( + {}, + { + set: ( + target: {[module: string]: string}, + prop: string, + value + ): boolean => { + console.log('prop', prop) + console.log('value', value) + console.log('target[prop]', target[prop]) + + if (!target[prop] || target[prop] !== value) { + target[prop] = value + this.requestUpdate() + } + + return true + }, + } + ) + render() { + let styles = '' + for (const subModule in this.shadowStyles) { + const style = this.shadowStyles[subModule] + styles += style + } + return html` + +
+ +
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + 'star-container': GaiaContainer + } +} + +export default GaiaContainer diff --git a/src/components/grid-container/contianer-interface.ts b/src/components/grid-container/contianer-interface.ts new file mode 100644 index 0000000..5c00768 --- /dev/null +++ b/src/components/grid-container/contianer-interface.ts @@ -0,0 +1,132 @@ +type Timer = number | undefined +type IndeterHTMLElement = HTMLElement | null +export interface ChildElementInfo { + pagination: number + row: number + column: number + folderName: string + anchorCoordinate: Coordinate + callback?: Function +} +interface ClickInfo { + pageX: number + pageY: number + clientX: number + clientY: number + translateX?: number + translateY?: number +} + +interface lastClickInfo extends ClickInfo { + timeStamp: number + offsetHeight: number + offsetWidth: number + offsetX: number + offsetY: number +} + +export interface Coordinate { + [key: string]: number[] | null + landscape: number[] | null + portrait: number[] | null +} + +export enum STATUS { + STATIC = 0, // 静置 + SWIPE = 1 << 1, // 划动 + DRAG = 1 << 2, // 拖动元素 + TURN = 1 << 3, // 翻页 + SORT = 1 << 4, // 整理 + OPEN_FORDER = 1 << 5, // 开启文件夹状态 +} + +export interface DragAndDrop { + // Whether drag-and-drop is enabled + enabled: boolean + + // The time, in ms, to wait before initiating a drag-and-drop from a + // long-press + delay: number + + // Timeout used to initiate drag-and-drop actions + timeout: Timer + + // The child that was tapped/clicked + child: any + + // Whether a drag is active + active: boolean + + // The start point of the drag action + start: ClickInfo + + // The last point of the drag action + last: lastClickInfo + + // Timeout used to send drag-move events + moveTimeout: Timer + + // The last time a move event was fired + lastMoveEventTime: number + + // Whether to capture the next click event + clickCapture: boolean + + // 下一个兄弟节点,用于跨页移动失败后返回原位置时定位用 + nextSibling: IndeterHTMLElement + previousSibling: IndeterHTMLElement + + // 是否正在跨页 + isSpanning: boolean + + // 正被悬浮覆盖的元素 + dropTarget: IndeterHTMLElement + + // 最后一个悬浮经过的元素 + lastDropChild: any + pagination: number + top: number + left: number +} + +export interface Container extends HTMLElement { + name: string + row: number + column: number + _frozen: boolean + _pendingStateChanges: Function[] + _children: any[] + _dnd: DragAndDrop + // 当前所显示的页面页码 + pagination: number + // 组件高度,也是页面高度,用于子组件越界判断 + height: number + width: number + // 页面列表 + pages: any + // 滑动偏移量 + _offsetX: number + // 本次触摸滑动距离 + distance: number + // 是否进入整理模式 + _sortMode: boolean + // 状态 + _status: typeof STATUS[keyof typeof STATUS] + // 交换元素位置时,无法进行交换的元素 + _staticElements: HTMLElement[] + // 用于首尾页划动的橡皮绳效果 + ratio: number + // 合并成文件夹计时器 + mergeTimer: number | undefined + // 文件夹 + folders: {[folderName: string]: any} + pageHeight: number + pageWidth: number + gridWidth: number + gridHeight: number + status: number + openedFolder: any + + reorderChild: Function + getChildByElement: Function +} diff --git a/src/components/grid-container/gaia-container-child.ts b/src/components/grid-container/gaia-container-child.ts new file mode 100644 index 0000000..01caca1 --- /dev/null +++ b/src/components/grid-container/gaia-container-child.ts @@ -0,0 +1,295 @@ +import GaiaContainer from './container' +import {Coordinate} from './contianer-interface' +/** + * Grid 布局 + * 组件属性: + * master: 占位元素,也用于定位静置时的组件实际位置 + * container: 显示元素,用于放置组件内容——element,也用于拖动时显示组件拖动位置 + * element: 组件实际内容,通过构造传参传入 + * + * 考虑: + * 1. 当为小组件时,插入 container 后需要锚固 + * 2. 锚固分 landscape 和 portrait 两种方向 + * 3. TBD:注意,需要考虑旋转屏幕后组件位置冲突问题,当旋转到某一方向时需要越界判断,越界 + * 时解除所有组件锚固状态重新排列并更新锚固位置 + */ +const defaultCoordinate = { + landscape: null, + portrait: null, +} + +export default class GaiaContainerChild { + _element: HTMLElement | null + _container: HTMLElement | null = null + _master: HTMLElement | null = null + isWidget: boolean + isFolder: boolean = false + row: number + column: number + manager: GaiaContainer + _isStatic: boolean | string + folderName: string = '' + anchorCoordinate: Coordinate + // 静态位置 + _lastMasterTop: number | null = null + _lastMasterLeft: number | null = null + _lastElementOrder: string | null = null + _lastElementDisplay: string | null = null + _lastElementHeight: number | null = null + _lastElementWidth: number | null = null + // 状态计时器 + removed: number | undefined = undefined + added: number | undefined = undefined + constructor( + element: HTMLElement | null, + row: number = 1, + column: number = 1, + anchorCoordinate: Coordinate | null, + manager: GaiaContainer + ) { + this._element = element + this.isWidget = element?.tagName === 'GAIA-WIDGET' + this.row = row + this.column = column + this.manager = manager + this._isStatic = false + this.anchorCoordinate = anchorCoordinate ?? defaultCoordinate // 两种屏幕方向的锚固坐标 + this.markDirty() + } + + rotate() { + const orientation = screen.orientation.type.split('-')[0] + + // 如果没有锚固坐标,则先解除锚固以自适应 + if (this.anchorCoordinate[orientation]) { + this.isStatic = false + + this.setArea() + } + // this.synchroniseMaster(); + this.isStatic = true + } + + get pagination() { + for (let i = 0; i < this.manager.pages.length; i++) { + if (this.manager.pages[i].compareDocumentPosition(this.master) & 16) { + return i + } + } + + throw new Error(`Can not find pagination`) + } + + get position() { + if (!this.master.parentElement) return 'unsettled' + + const inPage = this.master.parentElement.classList.contains( + 'gaia-container-page' + ) + return inPage ? 'page' : 'folder' + } + + get element() { + return this._element + } + + get isStatic() { + return this._isStatic + } + + set isStatic(value) { + !!value ? this.anchor() : this.loosen() + if (!this._isStatic && value) { + ;(this.element as HTMLElement).dispatchEvent( + new CustomEvent('anchor', { + detail: { + anchorCoordinate: this.anchorCoordinate, + }, + }) + ) + } + this._isStatic = !!value + } + + // 是否是页面最后一个组件 + get isTail() { + const page = this.manager.pages[this.pagination] + return page.lastChild === this.master + } + + /** + * 按记录的锚固坐标,将元素锚固到 Grid 网格中 + */ + anchor(type = 'recorder') { + const area = this.getArea(type) + if (!area) return + + const [rowStart, columStart] = area + const rowEnd = rowStart + +this.row + const columEnd = columStart + +this.column + this.master.style.gridArea = `${rowStart} / ${columStart} / ${rowEnd} / ${columEnd}` + ;(this._element as HTMLElement).dataset.static = type + } + + /** + * 获取元素所在 Grid 网格区域 + * @param {String} type 以何种方式获取网格区域: recorder 从记录中,current 从目前位置 + */ + getArea(type = 'recorder') { + const orientation = screen.orientation.type.split('-')[0] + + if (type === 'recorder' && this.anchorCoordinate[orientation]) { + return this.anchorCoordinate[orientation] + } + + const unitHeight = this.master.offsetHeight / this.row + const unitWidth = this.master.offsetWidth / this.column + const offsetTop = Math.abs(this.master.offsetTop) + const offsetLeft = Math.abs( + this.master.offsetLeft - this.pagination * this.manager.pageHeight + ) + const rowStart = Math.floor(offsetTop / unitHeight) + 1 + const columnStart = Math.floor(offsetLeft / unitWidth) + 1 + + return [rowStart, columnStart] + } + + /** + * 设置组件在 Grid 布局中的位置 + * @param {String} type 以何种方式设置网格区域: recorder 从记录中,current 从目前位置 + */ + setArea(type: string = 'recorder') { + const orientation = screen.orientation.type.split('-')[0] + this.anchorCoordinate[orientation] = this.getArea(type) + return this.anchorCoordinate[orientation] + } + + /** + * 解除元素的锚固 + */ + loosen() { + const orientation = screen.orientation.type.split('-')[0] + this.anchorCoordinate[orientation] = null + this.master.style.gridArea = 'unset' + this.master.style.gridRowStart = `span ${this.row}` + this.master.style.gridColumnStart = `span ${this.column}` + ;(this._element as HTMLElement).dataset.static = String(false) + } + + /** + * The element that will contain the child element and control its position. + */ + get container() { + if (!this._container) { + // Create container + let container = document.createElement('div') + container.classList.add('gaia-container-child') + container.style.position = 'absolute' + container.style.top = '0' + container.style.left = '0' + + // container.style.height = height + 'px'; + // container.style.width = width + 'px'; + container.style.setProperty('--columns', String(this.column)) + container.style.setProperty('--rows', String(this.row)) + + container.appendChild(this._element as HTMLElement) //this.element是div.icon-container + + this._container = container + } + return this._container + } + + changeSize(container = this.container) { + const {height, width} = this.master.getBoundingClientRect() + container.style.height = height + 'px' + container.style.width = width + 'px' + this.synchroniseContainer() + } + + /** + * The element that will be added to the container that will + * control the element's transform. + */ + get master() { + if (!this._master) { + // Create master + let master = document.createElement('div') + master.style.gridRowStart = `span ${this.row}` + master.style.gridColumnStart = `span ${this.column}` + master.style.height = '100%' + master.style.width = '100%' + master.className = 'container-master' + master.appendChild(this.container) + this._master = master + } + return this._master + } + + /** + * Clears any cached style properties. To be used if elements are + * manipulated outside of the methods of this object. + */ + markDirty() { + this._lastElementWidth = null + this._lastElementHeight = null + this._lastElementDisplay = null + this._lastElementOrder = null + this._lastMasterTop = null + this._lastMasterLeft = null + } + + get pageOffsetX() { + if (!this.master.parentElement) return 0 + return this.master.parentElement.offsetLeft + } + + /** + * Synchronise the size of the master with the managed child element. + */ + synchroniseMaster() { + let master = this.master + let element = this.element + + let style = window.getComputedStyle(element as HTMLElement) + let display = style.display + let order = style.order + let width = (element as HTMLElement).offsetWidth + let height = (element as HTMLElement).offsetHeight + + if ( + this._lastElementWidth !== width || + this._lastElementHeight !== height || + this._lastElementDisplay !== display || + this._lastElementOrder !== order + ) { + this._lastElementWidth = width + this._lastElementHeight = height + this._lastElementDisplay = display + this._lastElementOrder = order + + // master.style.width = width + "px"; + // master.style.height = height + "px"; + master.style.display = display + master.style.order = order + } + } + + /** + * Synchronise the container's transform with the position of the master. + */ + synchroniseContainer(isActive = false) { + let master = this.master + let container = this.container + let top = master.offsetTop + let left = master.offsetLeft + + if (this._lastMasterTop !== top || this._lastMasterLeft !== left) { + this._lastMasterTop = top + this._lastMasterLeft = left + !isActive && + !this.container.classList.contains('dragging') && + (container.style.transform = 'translate(' + left + 'px, ' + top + 'px)') + } + } +} diff --git a/src/components/grid-container/gaia-container-folder.ts b/src/components/grid-container/gaia-container-folder.ts new file mode 100644 index 0000000..183d6b1 --- /dev/null +++ b/src/components/grid-container/gaia-container-folder.ts @@ -0,0 +1,553 @@ +import GaiaContainerChild from './gaia-container-child' +import GaiaContainer from './container' + +/** + * 主屏文件夹,只允许 App 图标进入,且文件夹内图标数量大于2时才稳定存在 + */ + +export default class GaiaContainerFolder extends GaiaContainerChild { + // 文件夹名称 + name: string + // 图标 TagName + iconName: string = 'gaia-app-icon' + // 图标隐藏标题属性 + hideAttrName = 'hide-subtitle' + // 文件夹子节点 + _children: GaiaContainerChild[] = [] + isFolder: boolean = true + // 文件夹开启状态 + _status: number = 0 + // 文件夹处于图标状态的大小 + folderIconWidth: number = 0 + // 待添加文件 + suspendElement: HTMLElement[] = [] + _id: string + // 文件夹名元素 + _title: HTMLElement | null = null + // 开启文件夹动画计时器 + openTimer: number | undefined = undefined + constructor(manager: GaiaContainer, name?: string) { + super(null, 1, 1, null, manager) + this.name = this.checkAndGetFolderName(name) + this._id = `folder-${new Date().getTime()}` + this.init() + } + + init() { + this.addAnimationStyle() + this.container.addEventListener('touchstart', this) + this.container.addEventListener('touchmove', this) + this.container.addEventListener('touchend', this) + this.master.className = 'folder initializing' + this.master.id = this._id + this.master.addEventListener( + 'animationend', + () => { + this.master.classList.remove('initializing') + // NOTE: 避免同步 synchroniseContainer 产生不必要的动画 + this.container.style.setProperty('transition', 'unset') + this.synchroniseContainer() + setTimeout(() => this.container.style.removeProperty('transition')) + }, + {once: true} + ) + this.container.appendChild(this.title) + this.container.style.width = this.manager.gridWidth + 'px' + // this.manager.injectGlobalCss(this.shadowStyle, this.manager.name, 'gaia-container-folder'); + } + + get element() { + if ( + !this._element || + !this._element.classList.contains('gaia-container-folder') + ) { + const element = document.createElement('div') + + element.classList.add('gaia-container-folder') + this.folderIconWidth = this.manager.gridWidth * 0.6 + element.style.width = this.folderIconWidth + 'px' + element.style.height = this.folderIconWidth + 'px' + + this._element = element + } + return this._element + } + + get title() { + if (!this._title || this._title.innerHTML !== this.name) { + this._title?.remove() + this._title = document.createElement('div') + this._title.innerHTML = this.name + this._title.classList.add('folder-title') + this.container.appendChild(this._title) + } + return this._title + } + + get container() { + if (!this._container) { + // Create container + let container = document.createElement('div') + container.classList.add('gaia-container-child') + container.style.position = 'absolute' + container.style.top = '0' + container.style.left = '0' + + container.style.height = this.manager.gridHeight + 'px' + container.style.width = this.manager.gridWidth + 'px' + + container.appendChild(this.element) //this.element是div.icon-container + + this._container = container + this.master.appendChild(container) + } + return this._container + } + + get children() { + return this._children.map((child) => child.element) + } + + showIconsSubtitle(element: HTMLElement) { + const icon = element.querySelector(this.iconName) + icon && + icon.attributes.hasOwnProperty(this.hideAttrName) && + icon.attributes.removeNamedItem(this.hideAttrName) + } + + hideIconsSubtitle(element: HTMLElement) { + const icon = element.querySelector(this.iconName) + const attr = document.createAttribute(this.hideAttrName) + icon && icon.attributes.setNamedItem(attr) + } + + /** + * 将子节点从容器组件的节点表中挪到该文件夹的节点表中 + * @param {HTMLElement} element master + */ + movein(element: HTMLElement) { + let target + let targetIndex + this.manager._children.forEach((child: GaiaContainerChild, i) => { + if (child.master == element) { + target = child + targetIndex = i + return false + } + return true + }) + + if (!this.master.parentElement && target && targetIndex) { + // 无父节点即处于文件夹创建阶段,此时文件夹要代替 + // element 在容器组件子节点表中的位置 + this.manager.reorderChild( + this.element, + (target as GaiaContainerChild).element! + ) + targetIndex++ + } + + typeof targetIndex == 'number' && + this.manager._children.splice(targetIndex, 1) + } + + addAppIcon(element: HTMLElement, shouldOpen = false) { + const child = this.manager.getChildByElement(element) + + this._children.push(child!) + if (!this._status) { + this.hideIconsSubtitle(element) + } else { + this.showIconsSubtitle(element) + } + + if (!this._status && shouldOpen) { + this.suspendElement.push(element) + this.open() + } else { + this.movein(element) + this.element.appendChild(element) + } + child!.folderName = this.name + } + + removeAppIcon(node: GaiaContainerChild | HTMLElement) { + let removeChild = node + if (node instanceof HTMLElement) { + this._children = this._children.filter((child) => { + if (child.master == node && child.container == node) { + removeChild = child + return false + } + return true + }) + } + if (!removeChild) return null + ;(removeChild as GaiaContainerChild).folderName = '' + this.showIconsSubtitle((removeChild as GaiaContainerChild).container) + this.manager._children.push(removeChild as GaiaContainerChild) + return removeChild + } + + /** + * 检查文件夹名是否存在,存在则替换成 ‘新建文件夹n’ 样式的名字, + * 未传入文件名,则生成一个不重复的文件夹名 + */ + checkAndGetFolderName(folderName?: string, nth?: number): string { + const folders = this.manager.folders + if (!folderName) { + nth = nth ? ++nth : 1 + folderName = '新建文件夹' + } + + const name = folderName + (nth ? nth : '') + if (!folders[name]) { + return name + } + + return this.checkAndGetFolderName(undefined, nth) + } + + /** + * 开启文件夹,以父组件大小的形式展示,显示所有图标的 subtitle + */ + open() { + if (this._status) return + const self = this + this._status = 1 + this.master.classList.add('openning') + this._children.forEach((child) => this.showIconsSubtitle(child.master)) + this.manager.status |= 16 + this.manager.openedFolder = this + this.container.style.height = '100%' + this.container.style.width = '100%' + this.master.style.setProperty('z-index', String(10)) + this.container.style.removeProperty('--grid-height') + this.container.style.removeProperty('--grid-width') + + this.element.addEventListener('transitionend', function transitionend(evt) { + if (evt.target == self.element && evt.propertyName == 'height') { + self._children.forEach((child) => child.synchroniseContainer()) + } + self.container.style.setProperty('--folder-element-left', '0px') + self.container.style.setProperty('--folder-element-top', '0px') + if ( + self._children[self._children.length].master.compareDocumentPosition( + evt.target as HTMLElement + ) & 16 + ) + return + self.openTimer = setTimeout(() => { + self.master.classList.remove('openning') + self.master.classList.add('opened') + let element = self.suspendElement.shift() + while (element) { + self.movein(element) + self.element.appendChild(element) + element = self.suspendElement.shift() + } + }, 200) + self.element.removeEventListener('transitionend', transitionend) + }) + } + + /** + * 关闭文件夹,以父组件网格单元大小显示,同时作为文件夹销毁的入口, + * 当应用图标数量不足时,销毁该文件夹 + */ + close() { + if (!this._status) return + clearTimeout(this.openTimer) + this._children = this._children.filter((child) => { + if (child.container.classList.contains('dragging')) { + this.removeAppIcon(child) + return false + } else { + this.hideIconsSubtitle(child.master) + return true + } + }) + this.master.classList.add('closing') + this.master.classList.remove('opened') + this.manager.status &= ~16 + this.manager.openedFolder = null + this.container.style.height = this.manager.gridHeight + 'px' + this.container.style.width = this.manager.gridWidth + 'px' + this.element.addEventListener( + 'transitionend', + () => { + this._status = 0 + this._children.forEach((child) => child.synchroniseContainer()) + this.container.style.setProperty( + '--folder-element-left', + this.element.offsetLeft + 'px' + ) + this.container.style.setProperty( + '--folder-element-top', + this.element.offsetTop + 'px' + ) + this.master.style.removeProperty('z-index') + this.master.classList.remove('closing') + if (this._children.length <= 1) { + this.destroy() + } + }, + {once: true} + ) + } + + destroy() { + if (this._children.length > 1) { + return + } + + const {height: originHeight, width: originWidth} = + this.element.getBoundingClientRect() + const {height: targetHeight, width: targetWidth} = + this.element.getBoundingClientRect() + + const child = this._children[0] + const master = this._children[0].master + const childContainer = master.querySelector( + '.gaia-container-child' + ) as HTMLElement + + this.element.style.height = originHeight + 'px' + this.element.style.width = originWidth + 'px' + this.element.classList.add('scaling') // 剩下的唯一一个图标放大至原来大小展示 + this.master.classList.add('destroying') // 文件夹背景缩小消失 + + // nextTick,用以配合 originXXX 形成动画 + setTimeout(() => { + this.showIconsSubtitle(master) + this.element.style.height = targetHeight + 'px' + this.element.style.width = targetWidth + 'px' + }) + this.element.addEventListener( + 'transitionend', + () => { + this.master.style.position = 'absolute' + ;(this.master.parentElement as HTMLElement).insertBefore( + master, + this.master + ) + child.synchroniseContainer() + childContainer.style.transition = 'unset' + this.element.classList.remove('scaling') + + this.manager.dispatchEvent( + new CustomEvent('folder-destroy', { + detail: this, + composed: true, + }) + ) + + setTimeout(() => childContainer.style.removeProperty('transition')) + }, + {once: true} + ) + } + + handleEvent(evt: TouchEvent) { + switch (evt.type) { + case 'touchend': + if (this._status && evt.target === this.container) { + this.close() + } + case 'touchstart': + case 'touchmove': + if ( + this._status && + (evt.target as HTMLElement).tagName !== 'GAIA-APP-ICON' + ) { + evt.preventDefault() + evt.stopImmediatePropagation() + } + break + } + } + + addAnimationStyle() { + const styleArr = document.head.querySelectorAll('style') + let styleNode + styleArr.forEach((item) => { + try { + if (item.dataset?.name === 'gaia') { + styleNode = item + } + } catch (error) {} + }) + if (!styleNode) { + styleNode = document.createElement('style') + styleNode.dataset.name = 'gaia' + document.head.appendChild(styleNode) + } + styleNode.innerHTML += ` + @keyframes folder-fadein { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } + } + + ` + } + + get shadowStyle() { + return ` + ::slotted(.gaia-container-child) { + box-sizing: content-box; + } + ::slotted(.gaia-container-folder) { + transition: transform 0.2s, box-shadow 0.2s, height 0.2s, width 0.2s !important; + } + ::slotted(.folder:not(.opened)) .gaia-container-container { + } + ::slotted(.gaia-container-folder) { + display: grid; + position: unset; + grid-template-rows: repeat(3, 33.3%); + grid-template-columns: repeat(3, 33.3%); + grid-auto-flow: row dense; + height: 100%; + width: 100%; + background-color: rgba(255, 255, 255, 0.5); + box-shadow: 0 0 0px 3px rgba(255,255,255,0.5); + border-radius: 5px; + transition: transform 0.2s, box-shadow 0.2s, height 0.2s, width 0.2s !important; + } + + ::slotted(.gaia-container-folder::before) { + display: block; + position: absolute; + content: ''; + height: 100%; + width: 100%; + z-index: -1; + pointer-events: none; + transform: scale(1); + } + + ::slotted(.gaia-container-folder) .gaia-container-child { + height: 16.5% !important; + width: 16.5% !important; + } + + ::slotted(.gaia-container-folder) gaia-app-icon { + display: block; + width: 100%; + } + + ::slotted(.gaia-container-folder::after) { + content: ''; + z-index: 99; + height: 100%; + width: 100%; + pointer-events: all; + position: absolute; + } + ::slotted(.folder.opened) .gaia-container-folder::after { + pointer-events: none; + z-index: -1; + } + + ::slotted( ) .folder:not(.opened) .gaia-container-folder .gaia-container-child:not(.dragging) { + position: unset !important; + } + ::slotted(.folder.openning) .gaia-container-folder { + position: raletive; + } + ::slotted(.folder.initializing) .gaia-container-child { + position: unset !important; + transform: unset !important; + transition: unset !important; + } + + ::slotted(.folder.initializing) { + animation: folder-fadein 0.3s cubic-bezier(.08,.82,.17,1); + } + + ::slotted(.folder.opened) .gaia-container-folder { + z-index: -1; + } + + ::slotted(.folder) .container-master { + pointer-events: none; + } + ::slotted(.folder.opened) .container-master { + pointer-events: unset; + } + ::slotted(.destroying) .gaia-container-folder { + display: block; + } + ::slotted(.destroying) .gaia-container-folder::before { + transform-origin: bottom right; + transform: scale(0); + transition: transform 0.5s; + } + ::slotted(.destroying) .folder-title { + opacity: 0; + } + ::slotted(.scaling) .gaia-container-child { + position: unset !important; + } + + ::slotted(.folder-title) { + font-size: 1rem; + color: #fff; + line-height: 1rem; + margin-top: 3px; + opacity: 1; + transition: opacity 0.2s; + } + + ::slotted(.folder.opened) .folder-title, + ::slotted(.folder.openning) .folder-title { + opacity: 0; + } + + ::slotted(.gaia-container-folder) .gaia-container-child { + display: unset !important; + } + ::slotted(.folder.openning) > .gaia-container-child, + ::slotted(.folder.opened) > .gaia-container-child { + left: calc(var(--pagination) * var(--page-width)) !important; + transform: translate(0) !important; + } + + ::slotted(.folder.openning) > .gaia-container-child { + transform: translate(0) !important; + } + + ::slotted(.folder.openning) > .gaia-container-child, + ::slotted(.folder.closing) > .gaia-container-child { + transition: transform 0.2s, left 0.2s, height 0.2s, width 0.2s !important; + } + + ::slotted(.folder) > .gaia-container-child.dragging { + transition: unset !important; + } + + ::slotted(.folder.opened) .gaia-container-folder, + ::slotted(.folder.openning) .gaia-container-folder { + height: 50% !important; + max-height: 500px; + width: 50% !important; + max-width: 500px; + } + + ::slotted(.scaling) { + transform-origin: top left; + position: unset !important; + transition: width 0.5s !important; + } + + ::slotted(.folder.merging) .folder-title { + opacity: 0; + } + ` + } +} diff --git a/src/components/grid-container/gaia-container-page.ts b/src/components/grid-container/gaia-container-page.ts new file mode 100644 index 0000000..28dbed4 --- /dev/null +++ b/src/components/grid-container/gaia-container-page.ts @@ -0,0 +1,116 @@ +class GaiaContainerPage { + _pages: HTMLElement[] = [] // 存储所有添加进 gaia-container 的页面 + // 等待被移除的页面,仅在编辑、拖拽时出现,若结束前两种状态时仍然没有子节点,则删除 + _suspending: HTMLElement | null = null + _manager + observerCallback: MutationCallback + constructor(manager: any) { + this._manager = manager + + this._manager.addEventListener('statuschange', () => { + // gaia-container 退出拖拽模式,且有待删除页面 + if (!(this._manager._status & 2) && this._suspending) { + this.deletePage(this._suspending) + } + }) + + this.observerCallback = (mutations: MutationRecord[]) => { + for (const mutation of mutations) { + if (!(mutation.target as HTMLElement).children.length) { + const page = mutation.target as HTMLElement + const container = page.parentElement as any + + page.classList.add('removed') + const callback = () => { + if (!container || !page.dataset.page) return + if ( + container.pagination == page.dataset.page && + +page.dataset.page > 0 + ) { + container.smoothSlide(this._pages[--container.pagination]) + } + + if (this.editMode) { + this._suspending = page + } else { + this.deletePage(page) + } + } + container && callback() + } + } + } + + let proxy = new Proxy(this, { + get: (obj, property) => { + if (typeof property == 'string' && +property < obj._pages.length) { + return obj._pages[+property] + } + return (obj as any)[property] + }, + }) + + return proxy + } + + addPage = () => { + const div = document.createElement('div') + const pagination = `${this._pages.length}` + div.className = `gaia-container-page` + div.style.setProperty('--pagination', pagination) + this._pages.push(div) + div.dataset.page = `${this._pages.length - 1}` + this.observe(div) + + return div + } + + get editMode() { + return this._manager._status & 2 || this._manager._status & 8 + } + + observe = (page: HTMLElement) => { + let observe = new MutationObserver(this.observerCallback) + observe.observe(page, { + childList: true, + subtree: true, + }) + } + + deletePage = (page: HTMLElement) => { + if (page.children.length) return + let index = this._pages.indexOf(page) + + if (this.editMode && index == this.length - 1) { + // 处于拖拽状态时,尾页不被删除 + this._suspending = page + return + } + delete this._pages[index] + if (index > -1) { + page?.remove?.() + let flag = false + this._pages = this._pages.filter((page, i) => { + if (flag) { + ;(page.dataset.page as any) = --i + page.style.setProperty('--pagination', String(i)) + } + if (i == index) flag = true + return + }) + } + } + + get length() { + return this._pages.length + } + + forEach = (callback: Function) => { + const paginations = this._pages.length + for (let i = 0; i < paginations; i++) { + callback(this._pages[i], i, this._pages) + } + } +} + +export default GaiaContainerPage diff --git a/src/components/grid-container/gesture-manager.ts b/src/components/grid-container/gesture-manager.ts new file mode 100644 index 0000000..de75226 --- /dev/null +++ b/src/components/grid-container/gesture-manager.ts @@ -0,0 +1,69 @@ +class GestureManager { + element: HTMLElement + touches: Touch[] = [] + swipeTimer: number | undefined = undefined + velocity: number = 0.3 + duration: number = 10 + recorder: number = 0 + swipDirection: string = '' + + constructor(element: HTMLElement) { + this.element = element + this.listeneTouch() + } + + listeneTouch() { + this.element.addEventListener('touchstart', this) + this.element.addEventListener('touchmove', this) + this.element.addEventListener('touchend', this) + } + + handleEvent(evt: TouchEvent) { + switch (evt.type) { + case 'touchstart': + Array.prototype.forEach.call(evt.changedTouches, (touch: Touch) => { + ;(touch as any).timeStamp = evt.timeStamp + this.touches[touch.identifier] = touch + }) + break + case 'touchmove': + this.detectHorizonSwipe(evt) + break + case 'touchend': + Array.prototype.forEach.call( + evt.changedTouches, + (touch) => delete this.touches[touch.identifier] + ) + this.dispatchGesture() + break + } + } + + detectHorizonSwipe(event: TouchEvent) { + const {changedTouches, timeStamp: curTime} = event + Array.prototype.forEach.call(changedTouches, (touch) => { + const {identifier, pageX: curX} = touch + const {pageX: preX, timeStamp: preTime} = this.touches[identifier] as any + const velocity = (curX - preX) / (curTime - preTime) + this.swipDirection = velocity < 0 ? 'swipeleft' : 'swiperight' + this.recorder = velocity + + // TBD:暂停翻页动画,再划动手指时会出现速度计算异常 + if (Math.abs(velocity) > this.velocity) { + clearTimeout(this.swipeTimer) + this.swipeTimer = setTimeout( + () => (this.swipeTimer = undefined), + this.duration + ) + } + }) + } + + dispatchGesture() { + if (this.swipeTimer) { + this.element.dispatchEvent(new Event(this.swipDirection)) + } + } +} + +export default GestureManager diff --git a/src/test/panels/container/homescreen-container.ts b/src/test/panels/container/homescreen-container.ts new file mode 100644 index 0000000..155bc21 --- /dev/null +++ b/src/test/panels/container/homescreen-container.ts @@ -0,0 +1,61 @@ +import {html, css, LitElement, TemplateResult} from 'lit' +import {customElement, query, state} from 'lit/decorators.js' +import GaiaContainer from '../../../components/grid-container/container' +import './icon' + +@customElement('panel-container') +export class PanelContainer extends LitElement { + container!: GaiaContainer + @query('.reset') resetBtn!: HTMLElement + createRandomColor() { + function randomInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min)) + min + } + return ( + 'rgb(' + + randomInt(100, 200) + + ',' + + randomInt(100, 200) + + ',' + + randomInt(100, 200) + + ')' + ) + } + addAppIcon() { + ;(window as any).panel = this + console.log(this.container) + console.log('add') + const icon = document.createElement('site-icon') + + icon.setAttribute('color', this.createRandomColor()) + this.container.appendContainerChild(icon) + } + reset() { + console.log('reset') + } + firstUpdated() { + this.container = new GaiaContainer() + this.container.setAttribute('dragAndDrop', 'true') + this.shadowRoot?.appendChild(this.container) + ;(window as any).container = this.container + } + render() { + return html` + + + ` + } + + static styles = css` + star-container { + height: 90vh; + width: 100vw; + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'panel-container': PanelContainer + } +} diff --git a/src/test/panels/container/icon-style.ts b/src/test/panels/container/icon-style.ts new file mode 100644 index 0000000..a1b49f8 --- /dev/null +++ b/src/test/panels/container/icon-style.ts @@ -0,0 +1,75 @@ +import {css} from 'lit' + +export default css` + :host() { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + #display { + --background-color: #fff; + width: 20vw; + height: 12.5vh; + } + :host([hide-subtitle]) #subtitle { + opacity: 0; + } + + :host() #subtitle { + transition: opacity 0.2s; + } + + #image-container { + position: relative; + transition: visibility 0.2s, opacity 0.2s; + border: 50%; + background-color: var(--background-color); + } + + #image-container img { + display: block; + } + + #image-container.initial-load { + opacity: 0; + visibility: hidden; + } + + #image-container > #spinner { + display: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + #image-container.downloading > #spinner { + display: block; + background-size: contain; + animation: rotate 2s infinite linear; + } + + #image-container, + #image-container > div { + width: 100%; + } + + #subtitle { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + line-height: 1rem; + } + + @keyframes rotate { + from { + transform: rotate(1deg); + } + to { + transform: rotate(360deg); + } + } +` diff --git a/src/test/panels/container/icon.ts b/src/test/panels/container/icon.ts new file mode 100644 index 0000000..1971f53 --- /dev/null +++ b/src/test/panels/container/icon.ts @@ -0,0 +1,54 @@ +import {html, css, LitElement} from 'lit' +import {customElement, property, query} from 'lit/decorators.js' +import style from './icon-style' + +let count = 0 + +@customElement('site-icon') +export default class SiteIcon extends LitElement { + static defaultColor = '#fff' + _color = '#fff' + + @query('#subtitle') subtitle!: HTMLElement + @query('#image-container') imgContainer!: HTMLElement + @query('#display') displayImg!: HTMLElement + + set color(value: string) { + this._color = value + this.style.setProperty('--background-color', value) + } + @property({ + hasChanged: (newValue, oldValue) => { + return newValue === oldValue + }, + }) + get color() { + return this._color ?? SiteIcon.defaultColor + } + + static styles = style + changeColor(color: string) { + this.style.setProperty('--background-color', color) + } + firstUpdated() { + console.log('firstUpdated', this.displayImg) + } + render() { + this.changeColor(this.color) + return html` +
+
+ +
+
+
图标${++count}
+
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + 'site-icon': SiteIcon + } +} diff --git a/src/test/panels/root.ts b/src/test/panels/root.ts index 00a9607..26c1bf8 100644 --- a/src/test/panels/root.ts +++ b/src/test/panels/root.ts @@ -11,6 +11,7 @@ import './icon/icon' import './general/general' import './indicators/indicators' import './blur/use-blur' +import './container/homescreen-container' type SEID = String @@ -124,6 +125,14 @@ export class PanelRoot extends LitElement { href="#blur" >
+ +