Merge branch 'master' into featrue-component-slider

This commit is contained in:
wangchangqi 2022-09-09 09:48:42 +08:00
commit c135154f1a
22 changed files with 4733 additions and 17 deletions

View File

@ -10,4 +10,10 @@
- add blur
- add radio
- add toast
- add card
- add card
- add contaienr
- add SlotStyleHandler
- edit container folder animation
- optimize strategy of container
- add delay exchange feature
- fix bugs of container exchange stradegy

View File

@ -0,0 +1,64 @@
# star-container
容器组件提供了一个便捷易用的拖拽容器组件,可以根据传参不同,将在指定位置生成不同的子节点
## 组件结构
1. `container-page`: 分页组件,用于创建、管理、删除容器分页
2. `container-child`: 子节点组件,通过传参可以指定节点大小、位置,也用于给图标、组件定位
3. `container-folder`: 文件夹组件,用于创建、管理、删除文件夹
4. `gesture-manager`: 手势管理,用于提供多指手势判断
5. `element-exchange-strategy`: 子节点之间的交换规则
## 基本用法
在`HTML`文件下添加该`container`文件即可使用:
```html
<head>
<script src="grid-container/container.js"></script>
</head>
<body>
<star-container drag-and-drop></star-container>
</body>
```
之后可以在`js`中添加子节点:
```js
const container = document.querySelector('star-container')
container.appendContainerChild(element)
```
方法`appendContainerChild`可以接收第二个参数`options`,用以定制子节点的位置、大小、添加完成回调、文件名:
```ts
interface options {
row: number // 节点占的行数
column: number // 节点占的列数
anchorCoordinate: {
portrait: [number, number] // 竖屏时,节点左上角占据的位置
landscape: [number, number] // 横屏时,节点左上角占据的位置
}
callback: Function // 添加完成回调
folderName: string // 该图标所在的文件夹,若文件夹不存在则该图标所在位置自动生成一个文件夹
}
```
## 拖拽换位规则
交换、放置、移动规则,情景重合时,以越下方的规则为准:
1. 优先级高的可以挤占优先级低的子节点位置gridId
2. 图标节点、拖拽中的子节点可以挤占同优先级的子节点位置
3. 因位置被挤占而需要寻位的元素依照优先级priority的降序依次寻位
4. 被挤占节点(A)的寻位时的方向,与挤占节点(B)的 GridID 对比获得:
1. A.gridId < B.gridId: 向上
2. A.gridId % container.column < B.gridId % container.column:
向左
3. 以上两条,若其一的条件相反,相对应的方向也取反
5. 寻位时,优先尝试水平方向移动,后尝试垂直方向移动
6. 寻位方向并非绝对方向,以移动网格数量最短为优先条件
7. 为满足规则 5规则 6 的寻位方向变化以顺时针方向转变,如:上左 → 上右 → 下右 → 下左
8. 图标节点被另一个图标节点挤占位置时,仅能再去挤占相邻网格的图标的位置(即不允许挤占上下相邻图标)

View File

@ -0,0 +1,70 @@
import {css} from 'lit'
export default 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) {
width: 100%;
}
::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;
}
`

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,140 @@
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
landscapeGridId: number[] | null
portraitGridId: 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
// 中心坐标
gridPosition: {
x: number
y: 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
}

View File

@ -0,0 +1,644 @@
import GaiaContainer from './container'
import GaiaContainerChild from './gaia-container-child'
type PlacedRecorder = {
[pagination: number]: {[placedGridId: number]: GaiaContainerChild}
}
/**
*
* 1. gridId
* 2.
* 3. priority
* 4. (A)(B)GridID对比获得
* 1) A.gridId < B.gridId: 向上
* 2) A.gridId % container.column < B.gridId % container.column:
*
* 3)
* 5.
* 6.
* 7. 56
* 8.
*/
enum Directions {
BACKWARD = 1,
FORWARD = -1,
UPWARD = -1,
DOWN = 1,
}
export default class ExchangeStrategy {
manager: GaiaContainer
// 待加入网格的 container child
suspendChild: Set<GaiaContainerChild>[] = []
coordinateBak: GaiaContainer['childCoordinate'] | undefined
// 已做过移动的子节点表
placedChild: Set<GaiaContainerChild> = new Set()
// 移动记录
placedRecorder: PlacedRecorder = {}
constructor(manager: GaiaContainer) {
this.manager = manager
}
reset() {
this.suspendChild = []
this.coordinateBak = undefined
this.placedChild = new Set()
this.placedRecorder = {}
}
// 网格记录表,记录每个网格上放置的子元素
get coordinate() {
if (!this.coordinateBak) {
this.coordinateBak = []
this.manager.childCoordinate.forEach(
(recorder, pagination) =>
(this.coordinateBak![pagination] = {...recorder})
)
}
return this.coordinateBak
}
/**
*
* @param gridId
* @param recorder
*/
pickChild(
child: GaiaContainerChild,
recorder: {
[gridId: number]: GaiaContainerChild | undefined
}
) {
const {row, column} = child
const gridId = child.gridId
if (recorder[gridId] !== child) return false
for (let i = 0; i < row; i++) {
for (let j = 0; j < column; j++) {
delete recorder[gridId + j + i * this.manager.column]
}
}
return true
}
/**
*
*/
pickChildren(childrenArr: Set<GaiaContainerChild>[], pagination: number) {
const recorder = this.coordinate[pagination]
for (const children of childrenArr) {
if (!children) continue
for (const child of [...children]) {
if (!this.pickChild(child, recorder)) {
return false
}
}
}
return true
}
/**
*
* @param child
* @param gridId
* @param pagination
*/
placeChild(child: GaiaContainerChild, gridId: number, pagination: number) {
const recorder = this.coordinate[pagination]
const {row, column} = child
for (let i = 0; i < row; i++) {
for (let j = 0; j < column; j++) {
recorder[gridId + j + i * this.manager.column] = child
}
}
this.placedChild.add(child)
}
/**
*
* @param child
* @param dropChild
* @param directed
* @returns
*/
getExchangeDirection(
child: GaiaContainerChild,
dropChild: GaiaContainerChild | undefined,
directed?: [boolean, boolean]
): [Directions, Directions] {
if (!dropChild) return [0, 0]
const mColumn = this.manager.column
let horizontalDir, verticalDir
if (child.priority + dropChild.priority == 2) {
// 两个图标在进行交换
horizontalDir = directed
? directed[0]
? Directions.FORWARD
: Directions.BACKWARD
: child.gridId < dropChild.gridId
? Directions.BACKWARD
: Directions.FORWARD
// 当垂直方向为0时比起上下移动更优先横向、换行移动
verticalDir = 0
} else {
// 小组件参与交换
horizontalDir =
child.gridId % mColumn <= dropChild.gridId % mColumn
? Directions.BACKWARD
: Directions.FORWARD
verticalDir =
child.gridId > dropChild.gridId ? Directions.UPWARD : Directions.DOWN
}
return [horizontalDir, verticalDir]
}
allowPlace(
child: GaiaContainerChild,
comparisonArr: Set<GaiaContainerChild>[] | undefined,
mode: 'move' | 'place' = 'move'
) {
if (!comparisonArr || !comparisonArr.length) {
return true
}
for (let priority = comparisonArr.length - 1; priority > 1; priority--) {
if (comparisonArr[priority]?.size) {
if (
(priority == child.priority && mode == 'move') ||
priority > child.priority
) {
return false
} else if (priority < child.priority) {
break
}
}
}
return true
}
concatSuspendingChild(
targetArr: GaiaContainerChild[][] | Set<GaiaContainerChild>[]
) {
targetArr.forEach((children, priority) => {
if (!this.suspendChild[priority]) {
this.suspendChild[priority] = new Set(children)
} else {
children.forEach((child) => {
this.suspendChild[priority].add(child)
})
}
})
return this.suspendChild
}
/**
*
*/
handleCrossField = (
gridId: number,
child: GaiaContainerChild | undefined
) => {
const result = {
right: false,
bottom: false,
recommended: gridId,
}
if (!child || child.priority == 1) return result
const mColumn = this.manager.column
// 拖拽中的子节点右下角单元格所在的网格ID
let edge_bottom_right =
gridId + child.column - 1 + (child.row - 1) * mColumn
if (edge_bottom_right % mColumn < gridId % mColumn) {
// 右边界越界
result.right = true
gridId -= (edge_bottom_right % mColumn) + 1
edge_bottom_right -= (edge_bottom_right % mColumn) + 1
}
if (edge_bottom_right > 23) {
// 下边界越界
result.bottom = true
gridId -= Math.ceil((edge_bottom_right - 23) / mColumn) * mColumn
}
result.recommended = gridId
return result
}
/**
*
* @param dropChild
* @param direction
* @param pagination
* @param hasBeenTried
* @returns
*/
moveChild(
dropChild: GaiaContainerChild,
direction: [Directions, Directions],
pagination: number,
hasBeenTried = new Set([dropChild.gridId])
): boolean {
// 待尝试的网格
let willBeenTried: Set<number> = new Set()
// 因节点的优先级不够而无法放置的网格
let preserve: Set<number> = new Set()
// 优先级足以放置的网格
let access: Set<number> = new Set()
const recorder = this.coordinate[pagination]
const mColumn = this.manager.column
this.pickChild(dropChild, recorder)
// 以二维网格的方式遍历格子
const BFS = (
origin: number,
[horizontalDir, verticalDir]: [Directions, Directions],
callback: () => number[][] | undefined
) => {
const threshold = origin % mColumn
// 象限旋转方法是否为0来决定方向 direction 逆时针变换时采用的变换矩阵顺序
const quadrantType = horizontalDir + verticalDir
const horizontalConvert = [-1, 1]
const verticalConvert = [1, -1]
const convertOrder =
quadrantType !== 0
? [[1, 1], horizontalConvert, verticalConvert, horizontalConvert]
: [[1, 1], verticalConvert, horizontalConvert, verticalConvert]
let methods: number[][] | undefined
// 以 origin 为中心、step 为步长、顺时针沿着象限、先行后列收集范围内的网格ID
for (let step = 1; !methods?.length && step < 10; step++) {
let dir = [...direction]
// 顺时针
for (let time = 0; time < 4; time++) {
const convert = convertOrder[time]
const hdir = dir[0] * convert[0]
const vdir = dir[1] * convert[1]
dir = [hdir, vdir]
// 沿着行
for (let i = 0; i <= step; i++) {
// 沿着列
for (let j = 0; j > -1; j++) {
if (i + j > step) break
if (i + j !== step) continue
const horizontalStep = (hdir > 0 ? 1 : -1) * i
const verticalStep = (vdir > 0 ? 1 : -1) * j * mColumn
const testId = origin + horizontalStep + verticalStep
if (hasBeenTried.has(testId) || willBeenTried.has(testId))
continue
if (
((hdir > 0 && testId % mColumn >= threshold) || // 向后移动,测试网格取余后比原处取余大
(hdir < 0 && testId % mColumn <= threshold)) && // 向前移动,测试网格取余后比原处取余小
testId >= 0 &&
testId <= 23
) {
willBeenTried.add(testId)
}
}
}
}
methods = callback()
}
return methods?.length ? methods : undefined
}
// 以一维轴的方式遍历格子
const BFSinAxios = (origin: number, direction: number) => {
let gridId: number | undefined
const ergodic = (direction: number) => {
for (
let i = 1, testId = origin + i * direction;
testId < 24 && testId > -1;
i++, testId = origin + i * direction
) {
// 获取测试网格之中的子节点
const dropGrid = this.coordinate[pagination][testId]
if (
!dropGrid || // 测试网格为空格子时
(!this.placedChild.has(dropGrid) && dropGrid.priority < 2) // 子节点为图标节点,且未移动过
) {
gridId = testId
break
}
}
}
ergodic(direction)
if (gridId === undefined) {
ergodic(-1 * direction)
}
return gridId !== undefined ? [[gridId]] : gridId
}
const place = () => {
let methods: number[][] = []
for (const grid of [...willBeenTried]) {
hasBeenTried.add(grid)
let gridMethods = this.getAllPlacedMethods(
dropChild,
grid,
pagination,
preserve,
access
)
if (gridMethods.length) {
for (let grids of gridMethods) {
const dropChildren = this.getChildrenByGridArea(
grids[0],
dropChild.row,
dropChild.column,
pagination,
dropChild.priority
)
if (dropChildren.canPlace) {
methods.push(...gridMethods)
}
}
}
}
willBeenTried = new Set()
return methods
}
const chooseMethod = (methods: number[][]) => {
let gridId: number | undefined
let step = 9
if (methods) {
for (const method of methods) {
const diff = Math.abs(method[0] - dropChild.gridId)
const methodStep = (diff % mColumn) + Math.floor(diff / mColumn)
// 保证取所有方法中移动步数最少的
if (step >= methodStep) {
step = methodStep
} else {
continue
}
if (
Math.floor(method[0] / mColumn) ==
Math.floor(dropChild.gridId / mColumn) // 优先选择同行位移gridId 左右移动)
) {
gridId = method[0]
// break
} else if (
Math.abs(method[0] - dropChild.gridId) % mColumn ==
0 // 其次选择同列位移gridId 上下移动)
) {
gridId = method[0]
}
}
}
gridId = gridId ?? methods[0][0]
return gridId
}
const methods = direction[1]
? BFS(dropChild.gridId, direction, place)
: BFSinAxios(dropChild.gridId, direction[0])
const gridId = methods ? chooseMethod(methods) : undefined
if (typeof gridId == 'number') {
if (this.placedRecorder[pagination]) {
this.placedRecorder[pagination][gridId] = dropChild
} else {
this.placedRecorder[pagination] = {[gridId]: dropChild}
}
const conflictChildren = this.getChildrenByGridArea(
gridId,
dropChild.row,
dropChild.column,
pagination,
dropChild.priority
)
this.placedChild.add(dropChild)
this.pickChildren(conflictChildren.dropChildren, pagination)
this.concatSuspendingChild(conflictChildren.dropChildren)
this.placeChild(dropChild, gridId, pagination)
}
return gridId !== undefined ? true : false
}
/**
* ID,
*
*/
getGridIds(child: GaiaContainerChild, assumeId: number | undefined) {
if (assumeId === undefined || child.priority == 1) {
assumeId = assumeId ?? child.gridId
} else if (assumeId < 0 || assumeId > 23) {
throw new Error('assumeId invalid')
} else {
// 使用假设ID时需要进行越界检查
const crossFildeResult = this.handleCrossField(assumeId, child)
if (crossFildeResult.bottom || crossFildeResult.right) {
return []
}
}
let gridIds = []
for (let i = 0; i < child.row; i++) {
for (let j = 0; j < child.column; j++) {
gridIds.push(assumeId + j + i * this.manager.column)
}
}
return gridIds
}
/**
*
* ID集合的数组
* @param child
* @param grid
* @param preserve
* @returns
*/
getAllPlacedMethods(
child: GaiaContainerChild,
grid: number,
pagination: number,
preserve: Set<number>,
access: Set<number>
): number[][] {
let methods: number[][] = []
const recorder = this.coordinate[pagination]
for (let j = child.column - 1; j > -1; j--) {
for (let i = child.row - 1; i > -1; i--) {
// 挨个尝试放置节点
const testId = grid - j - i * this.manager.column
if (testId < 0 || testId > 23) continue
const method = this.getGridIds(child, testId)
if (method.length) {
let flag = true
for (const grid of method) {
if (preserve.has(grid)) {
flag = false
break
}
if (!access.has(grid)) {
const dropChild = recorder[grid]
if (
!dropChild ||
(!this.placedChild.has(dropChild) &&
dropChild.priority < child.priority)
) {
access.add(grid)
} else {
preserve.add(grid)
flag = false
break
}
}
}
flag && methods.push(method)
}
}
}
return methods
}
/**
*
* @param child
* @param gridId ID
* @param pagination
* @returns
*/
tryToPlace(
child: GaiaContainerChild,
gridId: number,
pagination: number,
mode: 'place' | 'move',
direction: [boolean, boolean]
): {
canPlace: boolean
placedRecorder?: PlacedRecorder
childCoordinate?: GaiaContainer['childCoordinate']
} {
const {column: mColumn} = this.manager
// 子节点右下角单元格所在的网格ID
const edge_bottom_right =
gridId + child.column - 1 + (child.row - 1) * mColumn
this.reset()
// 越界判断
if (
edge_bottom_right > 23 || // 下边界越界
edge_bottom_right % mColumn < gridId % mColumn // 右边界越界
) {
return {canPlace: false}
}
const recorder = this.coordinate[pagination]
this.placedRecorder = {}
this.placedChild.add(child)
this.pickChild(child, recorder)
const dropChild = recorder[gridId]
// 获取被挤占位置的元素,按照优先级排序
const pickChildren = this.getChildrenByGridArea(
gridId,
child.row,
child.column,
pagination,
child.priority,
'place'
)!
// 检查该子节点与被挤占位置的元素之间的优先级,若存在优先级高的元素,该次放置失败
const canPlace = this.allowPlace(child, pickChildren.dropChildren, mode)
if (!canPlace || !pickChildren.canPlace) {
return {canPlace: false}
}
// 被挤出原本位置的元素
this.suspendChild = pickChildren.dropChildren ?? []
this.pickChildren(this.suspendChild, pagination)
// 放置该子节点
this.placeChild(child, gridId, pagination)
// 尝试移动并放置被挤出去的子节点
try {
for (let i = 0; i < this.suspendChild.length; i++) {
let j = this.suspendChild.length - i
let suspendArr = this.suspendChild[j]
if (suspendArr?.size) {
suspendArr.forEach((suspendChild) => {
const exchangeDirection = this.getExchangeDirection(
child,
suspendChild,
direction
)
if (!this.moveChild(suspendChild, exchangeDirection, pagination)) {
// 移动失败
throw suspendChild
}
})
}
}
} catch (placeFaild) {
console.info('try to place failed!')
return {canPlace: false}
}
return {
canPlace: true,
placedRecorder: this.placedRecorder,
childCoordinate: this.coordinate,
}
}
/**
*
*
* priority
*/
getChildrenByGridArea(
gridId: number,
row: number,
column: number,
pagination: number,
priority: number = 0,
mode: 'move' | 'place' = 'move'
) {
const recorder = this.coordinate[pagination]
let children: Set<GaiaContainerChild>[] = []
let canPlace = true
for (let i = 0; i < row; i++) {
for (let j = 0; j < column && canPlace; j++) {
const targetGrid = gridId + j + i * this.manager.column
const child = recorder[targetGrid]
if (child) {
if (
this.placedChild.has(child) ||
child.priority > priority ||
(mode == 'move' &&
child.priority == priority &&
child.priority != 1)
) {
canPlace = false
break
}
if (children[child.priority]) {
children[child.priority].add(child)
} else {
children[child.priority] = new Set([child])
}
}
}
}
return {dropChildren: children, canPlace}
}
}

View File

@ -0,0 +1,33 @@
@startuml exchange
StarContainer -> tryToPlace: 尝试将拖拽节点放置到指定网格区域中
tryToPlace -> getDropChildrenByGridID: 获取网格区域中的子节点集
getDropChildrenByGridID --> tryToPlace: 返回一个按优先级顺序排序的二维数组
tryToPlace -> allowPlace: 检查拖拽节点能否放置
alt 将占据的网格中存在更高优先级的节点
allowPlace --> tryToPlace: 无法放置
tryToPlace --> StarContainer: 不提供放置换位策略
else 可以放置
allowPlace --> tryToPlace: 可以放置
tryToPlace -> pickChildren: 从网格记录表中摘除被挤占位置的节点记录
tryToPlace -> pickChild: 从网格记录表中摘除拖拽节点记录
tryToPlace -> placeChild: 放置拖拽节点
tryToPlace -> getExchangeDirection: 被挤占节点获取寻位方向
getExchangeDirection --> tryToPlace: 返回一个表示方向的数组
tryToPlace -> moveChild: 按优先级从大到小地遍历被挤占节点,并尝试位移
note over moveChild
为确保能保证移动格数最小,
以步进、顺时针沿象限、先行后列地BFS遍历
出被挤占节点可达网格
endnote
moveChild -> getAllPlacedMethods: 获取节点放置在可达网格上的方法
alt 存在放置方法
getAllPlacedMethods --> moveChild: 返回一个
moveChild -> getDropChildrenByGridID: 获取方法提供区域中的所有子节点
else 不存在放置方法
moveChild --> tryToPlace: 位移失败
tryToPlace --> StarContainer: 不提供换位策略
end
end
@enduml

View File

@ -0,0 +1,349 @@
import GaiaContainer from './container'
import {Coordinate} from './contianer-interface'
/**
* Grid
*
* master
* container element
* element
*
*
* 1. container
* 2. landscape portrait
* 3. TBD
*
*/
const defaultCoordinate: Coordinate = {
landscape: null,
portrait: null,
landscapeGridId: null,
portraitGridId: null,
}
type anchorType = 'recorder' | 'current'
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
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
_lastElementTop: number | null = null
_lastElementLeft: number | null = null
// 状态计时器
removed: number | undefined = undefined
added: number | undefined = undefined
// 优先级 = rows × columns
priority: number = 1
// 网格位置
preGridId: number = 0
curGridId: number = -1
// 中心位置
center: {x: number; y: number} = {x: 0, y: 0}
constructor(
element: HTMLElement | null,
row: number = 1,
column: number = 1,
anchorCoordinate: Coordinate | undefined,
manager: GaiaContainer
) {
this._element = element
this.isWidget = element?.tagName === 'GAIA-WIDGET'
this.row = row
this.column = column
this.priority = row * 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 gridId() {
return this.curGridId
}
set gridId(value: number) {
// if (value !== this.curGridId) {
const orientation = screen.orientation.type.split('-')[0]
const rowStart = Math.floor(value / this.manager.column) + 1
const columnStart = (value % this.manager.column) + 1
this.anchorCoordinate[orientation] = [rowStart, columnStart]
this.anchor()
// }
}
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 folderName() {
const name = this.master.parentElement?.dataset.name
return name || ''
}
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: anchorType = 'recorder') {
const area = this.getArea(type)
if (!area) return
const [rowStart, columnStart] = area
const rowEnd = rowStart + this.row
const columnEnd = columnStart + this.column
this.center.x = columnStart + this.column / 2 - 1
this.center.y = rowStart + this.row / 2 - 1
this.master.style.gridArea = `${rowStart} / ${columnStart} / ${rowEnd} / ${columnEnd}`
;(this._element as HTMLElement).dataset.static = type
}
/**
* Grid
* @param {String} type recorder current
*/
getArea(type: anchorType = '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: anchorType = '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
this._lastElementTop = null
this._lastElementLeft = 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
const position = this.position
// 左上角的 GridID
const gridId =
position == 'page'
? this.manager.getGridIdByCoordinate(left, top, this.pagination)
: this.manager.getFolderGridIdByCoordinate(left, top, this.pagination)
if (gridId !== this.curGridId) {
this.manager.recordCoordinate(this, this.pagination, gridId)
this.preGridId = this.curGridId
this.curGridId = gridId
}
if (this._lastMasterTop !== top || this._lastMasterLeft !== left) {
this._lastMasterTop = top
this._lastMasterLeft = left
this._lastElementLeft = this.element?.offsetLeft!
this._lastElementHeight = this.element?.offsetHeight!
!isActive &&
!this.container.classList.contains('dragging') &&
(container.style.transform = 'translate(' + left + 'px, ' + top + 'px)')
}
}
}

View File

@ -0,0 +1,559 @@
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
// 子节点坐标
childCoordinate: {[gridId: number]: GaiaContainerChild | undefined}[] = [{}]
gridHeight: number = 0
gridWidth: number = 0
constructor(manager: GaiaContainer, name?: string) {
super(null, 1, 1, undefined, manager)
this.name = this.checkAndGetFolderName(name)
this._id = `folder-${new Date().getTime()}`
this.init()
}
init() {
this.manager.editStyle(
'GaiaContainerFolder',
GaiaContainerFolder.shadowStyle()
)
this.container.addEventListener('touchstart', this)
this.container.addEventListener('touchmove', this)
this.container.addEventListener('touchend', this)
this.element.dataset.name = this.name
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'
}
resize() {
let element = this.element
this._lastElementTop = element?.offsetTop!
this._lastElementLeft = element?.offsetLeft!
this.gridHeight = this._children[0]._lastElementHeight!
this.gridWidth = this._children[0]._lastElementWidth!
}
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) {
element.removeAttribute(this.hideAttrName)
}
hideIconsSubtitle(element: HTMLElement) {
element.setAttribute(this.hideAttrName, '')
}
/**
*
* @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(child?.element!)
} else {
this.showIconsSubtitle(child?.element!)
}
if (!this._status && shouldOpen) {
this.suspendElement.push(element)
this.open()
} else {
this.movein(element)
this.element.appendChild(element)
}
}
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
this.showIconsSubtitle((removeChild as GaiaContainerChild).element!)
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
this._status = 1
this.master.classList.add('openning')
this._children.forEach((child) => this.showIconsSubtitle(child.element!))
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', this.openTransition)
}
openTransition = (evt: TransitionEvent) => {
if (evt.target == this.element && evt.propertyName == 'height') {
this._children.forEach((child) => child.synchroniseContainer())
let element = this.element
this._lastElementTop = element?.offsetTop!
this._lastElementLeft = element?.offsetLeft!
}
if (
this._children[this._children.length - 1].master.compareDocumentPosition(
evt.target as HTMLElement
) & 16
) {
return
}
this.container.style.setProperty('--folder-element-left', '0px')
this.container.style.setProperty('--folder-element-top', '0px')
this.openTimer = setTimeout(() => {
this.master.classList.remove('openning')
this.master.classList.add('opened')
let element = this.suspendElement.shift()
while (element) {
this.movein(element)
this.element.appendChild(element)
element = this.suspendElement.shift()
}
this.gridHeight = this._children[0]._lastElementHeight!
this.gridWidth = this._children[0]._lastElementWidth!
})
this.element.removeEventListener('transitionend', this.openTransition)
}
/**
*
*
*/
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.element!)
return true
}
})
this.master.classList.remove('openning')
this.master.classList.remove('opened')
this.master.classList.add('closing')
this.element.removeEventListener('transitionend', this.openTransition)
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()
}
this.gridHeight = this._children[0]._lastElementHeight!
this.gridWidth = this._children[0]._lastElementWidth!
},
{once: true}
)
}
destroy() {
if (this._children.length > 0) {
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
this.manager._children.push(child)
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(this._children[0].element!)
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 !== 'SITE-ICON'
) {
evt.preventDefault()
evt.stopImmediatePropagation()
}
break
}
}
static shadowStyle() {
return `
::slotted(.gaia-container-child) {
box-sizing: content-box;
}
::slotted(.gaia-container-folder) {
display: grid;
position: unset;
grid-template-rows: repeat(4, 25%);
grid-template-columns: repeat(4, 25%);
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: 12.5% !important;
width: 12.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) .container-master {
position: relative;
}
::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;
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 {
transition: left 0.2s, height 0.2s, width 0.2s !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;
}
@keyframes folder-fadein {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
`
}
}

View File

@ -0,0 +1,122 @@
import GaiaContainer from './container'
class GaiaContainerPage {
_pages: HTMLElement[] = [] // 存储所有添加进 gaia-container 的页面
// 等待被移除的页面,仅在编辑、拖拽时出现,若结束前两种状态时仍然没有子节点,则删除
_suspending: HTMLElement | null = null
_manager: GaiaContainer
observerCallback: MutationCallback
constructor(manager: GaiaContainer) {
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)
this._manager.childCoordinate[this._pages.length - 1] = {}
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
let coordinates: GaiaContainer['childCoordinate'] = []
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
coordinates[i] = this._manager.childCoordinate[i - +flag]
return
})
this._manager.childCoordinate = coordinates
}
}
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

View File

@ -0,0 +1,71 @@
import GaiaContainer from './container'
class GestureManager {
element: GaiaContainer
touches: Touch[] = []
swipeTimer: number | undefined = undefined
velocity: number = 0.3
duration: number = 10
recorder: number = 0
swipDirection: string = ''
constructor(element: GaiaContainer) {
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

View File

@ -0,0 +1,9 @@
import {css} from 'lit'
export default css`
:host {
/* 图标大小 */
--icon-size: 108px;
--icon-size: 50%;
}
`

View File

@ -33,7 +33,7 @@ export class StarAnimateSection extends LitElement {
position: absolute;
width: 100%; /* 100vw会有x轴溢出 */
height: 100vh;
overflow: auto;
overflow: hidden;
/* height: calc(100vh + 1px); */ /* 手动制造溢出 */
animation-duration: 0.3s;
background-color: #f0f0f0;

View File

@ -2,17 +2,28 @@
星光 Web 组件 --- Switch 组件8 月 29 日)
`star-switch` 组件包含**种**互相独立的属性,介绍如下:
`star-switch` 组件包含**种**互相独立的属性,介绍如下:
1. switchcolor : 控制 switch 的选中背景颜色,默认为蓝色`#0265dc`。
```
<star-switch></star-switch>
<hr />
<star-switch switchcolor="#4cd964"></star-switch>
<star-switch switchcolor="#4cd964">ios绿</star-switch>
<star-switch switchcolor="#ff3b30">ios红</star-switch>
```
2. checked : 用于选择对应 switch 首次加载时是否被选中,默认为`false`。
2. switchicon : 用于控制内含文本,默认为`false`。
```
<star-switch></star-switch>
<hr />
<star-switch switchicon></star-switch>
<hr />
<star-switch checked switchicon></star-switch>
```
3. checked : 用于选择对应 switch 首次加载时是否被选中,默认为`false`。
```
<star-switch></star-switch>
@ -22,7 +33,7 @@
<star-switch checked switchcolor="#4cd964"></star-switch>
```
3. disabled : 控制 switch 是否**禁用**状态,默认为`false`。
4. disabled : 控制 switch 是否**禁用**状态,默认为`false`。
```
<star-switch></star-switch>
@ -32,7 +43,7 @@
<star-switch disabled checked></star-switch>
```
4. size : 控制 switch 的大小,包括 small、medium、large 和 extralarge默认为 `medium`
5. size : 控制 switch 的大小,包括 small、medium、large 和 extralarge默认为 `medium`
```
<star-switch size="small" switchcolor="#4cd964"></star-switch>
@ -45,3 +56,7 @@
<hr />
<star-switch size="extralarge" disabled checked></star-switch>
```
该组件支持焦点选中,且可以在焦点选中后任意拖动。
拖动后可间接触发 checked 开关。

View File

@ -13,6 +13,10 @@ export class StarSwitch extends LitElement {
@property({type: Number}) right = 0
@property({type: Number}) left = 0
@property({type: Number}) switchx = 0
@property({type: Number}) startx = 0
@property({type: Number}) _x = 0
@property({type: Number}) rightx = 0
@property({type: Number}) leftx = 10000
@property({type: Number}) x = 0
@property({type: Boolean}) disabled = false
@property({type: Boolean}) checked = false
@ -40,27 +44,87 @@ export class StarSwitch extends LitElement {
id="base"
switchcolor="#0265dc"
/>
<label id="switchBall" for="base" @touchmove=${this.selectTouchMove}>
<label
id="switchBall"
for="base"
@touchstart=${this.touchStart}
@touchend=${this.touchEnd}
@touchmove=${this.selectTouchMove}
>
<div class="${this.checked ? 'iconTrue' : 'iconFalse '}"></div>
</label>
`
}
private touchStart(evt: TouchEvent) {
console.log('##')
this.startx = evt.touches[0].clientX
this._x = this.startx
}
private touchEnd(evt: TouchEvent) {
this.rightx = 0
this.leftx = 10000
this.startx = 0
}
private selectTouchMove(evt: TouchEvent) {
evt.preventDefault()
// disabled不允许拖动
if (!this.disabled) {
let right = this.switchBall.getBoundingClientRect().right
let left = this.switchBall.getBoundingClientRect().left
let switchx = (right - left) / 2 + left
let switchx = (right - left) / 2
let x = evt.touches[0].clientX
if (x >= switchx) {
this.base.checked = true
// 解决touchmove监测不到checked变化
this.checked = true
} else {
this.base.checked = false
// 解决touchmove监测不到checked变化
this.checked = false
// 向左滑
if (x < this._x) {
// console.log('往左滑', x, this._x)
this.leftx = this.leftx > this._x ? this._x : this.leftx
//到最左端后向右走 switchx 变true
if (x > this.leftx + this.switchx) {
this.base.checked = true
// 解决touchmove监测不到checked变化
this.checked = true
this.leftx = 10000
}
//初始的开关阈值调节
if (x > left && x < right) {
if (this.startx - this.leftx > switchx) {
this.base.checked = false
this.checked = false
}
}
//有rightx的情况下向左移动 switch 变false
if (x < this.rightx - switchx) {
this.base.checked = false
this.checked = false
}
} else if (x > this._x) {
//向右滑
// console.log('往右滑', x, this._x)
this.rightx = this.rightx > this._x ? this.rightx : this._x
//到最右端后向左移动 switch 变false
if (x < this.rightx - this.switchx) {
this.base.checked = false
this.checked = false
this.rightx = 0
}
//初始的开关阈值调节
if (x > left && x < right) {
if (this.rightx - this.startx > switchx) {
this.base.checked = true
this.checked = true
}
}
//到最左端开始向右走 switchx 变true
if (x - this.leftx > switchx) {
this.base.checked = true
this.checked = true
this.leftx = 10000
}
}
//更新_x的值保持_x在x后面
this._x = x
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,151 @@
import {html, css, LitElement, TemplateResult} from 'lit'
import {customElement, query, state} from 'lit/decorators.js'
import GaiaContainer from '../../../components/grid-container/container'
import GaiaContainerChild from '../../../components/grid-container/gaia-container-child'
import homescreenStyle from './homescreen-style'
import './icon'
@customElement('panel-homescreen')
export class PanelContainer extends LitElement {
container!: GaiaContainer
icons: {[prop: string]: GaiaContainerChild} = {}
@query('.reset') resetBtn!: HTMLElement
@query('#row') rowInput!: HTMLInputElement
@query('#column') columnInput!: HTMLInputElement
@query('#indicator') indicator!: 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(x?: number, y?: number) {
x =
typeof x == 'number' ? x : this.rowInput.value ? +this.rowInput.value : 1
y = y ? y : this.columnInput.value ? +this.columnInput.value : 1
const icon = document.createElement('site-icon')
icon.setAttribute('color', this.createRandomColor())
this.container.appendContainerChild(icon, {
row: x,
column: y,
})
this.icons[icon.name] = this.container.getChildByElement(icon)!
}
reset() {}
firstUpdated() {
for (let i = 0; i < 24; i++) {
const index = document.createElement('div')
index.classList.add('index')
index.dataset.number = String(i)
this.indicator.appendChild(index)
}
this.container = new GaiaContainer()
this.container.setAttribute('drag-and-drop', '')
this.shadowRoot?.appendChild(this.container)
this.container.sortMode = true
;(window as any).container = this.container
;(window as any).home = this
this.addAppIcon(2, 2)
// this.addAppIcon(2, 2)
}
@state() test!: string
@state()
protected styles: {[subModule: string]: string} = new Proxy(
{},
{
set: (
target: {[module: string]: string},
prop: string,
value
): boolean => {
if (!target[prop] || target[prop] !== value) {
target[prop] = value
this.requestUpdate()
}
return true
},
}
)
render() {
let style = ''
for (const subModule in this.styles) {
const str = this.styles[subModule]
style += str
style += '\n'
}
return html`
<style>
${style}
</style>
<div class="btns">
<button class="add" @click=${this.addAppIcon}></button>
<button class="reset" @click=${() => this.addAppIcon(1, 1)}>1×1</button>
<button class="reset" @click=${() => this.addAppIcon(2, 2)}>2×2</button>
<input id="row" placeholder="row" value="3" />
<input id="column" placeholder="column" value="2" />
<div id="indicator"></div>
</div>
`
}
static styles = [
css`
star-container {
height: 90vh;
width: 100vw;
}
.btns {
position: fixed;
top: 0;
left: 0;
z-index: 1;
}
#indicator {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
display: grid;
grid-template-rows: repeat(6, 16.66%);
grid-template-columns: repeat(4, 25%);
height: 90vh;
width: 100vw;
z-index: 100;
background-color: rgba(1, 1, 0, 0.05);
}
.index::before {
content: attr(data-number);
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
font-size: 4rem;
color: rgba(1, 1, 1, 0.15);
}
.gaia-container-child {
border: 1px solid black;
}
`,
homescreenStyle,
]
}
declare global {
interface HTMLElementTagNameMap {
'panel-homescreen': PanelContainer
}
}

View File

@ -0,0 +1,9 @@
import {css} from 'lit'
export default css`
/* 图标归位时的动画 */
star-container:not(.loading)
.gaia-container-child:not(.added):not(.dragging) {
transition: transform 0.2s;
}
`

View File

@ -0,0 +1,92 @@
import {css} from 'lit'
export default css`
:host([color]) {
position: var(--width, absolute);
top: 50%;
left: 50%;
width: var(--icon-size, 50%);
height: var(--icon-size, 50%);
max-width: var(--width);
max-height: var(--width);
border-radius: 50%;
}
#display {
--background-color: #fff;
width: 100%;
}
:host([hide-subtitle]) #subtitle {
opacity: 0;
}
:host() #subtitle {
transition: opacity 0.2s;
}
#image-container {
position: relative;
transition: visibility 0.2s, opacity 0.2s;
border-radius: 50%;
background-color: var(--background-color);
}
#image-container img {
display: block;
opacity: 0;
}
#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%;
height: 100%;
} */
#subtitle {
/* 小黑 文字 中 */
margin-top: 6px;
height: 26px;
/* 小正文-浅-居中 */
font-family: 'OPPOSans';
font-style: normal;
font-weight: 400;
font-size: 20px;
line-height: 26px;
/* identical to box height, or 130% */
text-align: center;
/* 字体/ 高亮白 */
color: #fafafa;
text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.28);
}
@keyframes rotate {
from {
transform: rotate(1deg);
}
to {
transform: rotate(360deg);
}
}
`

View File

@ -0,0 +1,69 @@
import {html, css, LitElement} from 'lit'
import {customElement, property, query} from 'lit/decorators.js'
import style from './icon-style'
let count = 0
let imgBlob!: Blob
const getImage = (): Promise<Blob> => {
return new Promise((res, rej) => {
if (imgBlob) return res(imgBlob)
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
canvas.toBlob((blob) => (blob ? res(blob) : rej()))
})
}
@customElement('site-icon')
export default class SiteIcon extends LitElement {
static defaultColor = '#fff'
_color = '#fff'
name: string = `图标${++count}`
@query('#subtitle') subtitle!: HTMLElement
@query('#image-container') imgContainer!: HTMLElement
@query('#display') displayImg!: HTMLImageElement
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() {
const {width} = this.getBoundingClientRect()
this.style.setProperty('--width', width + 'px')
getImage().then((blob: Blob) => {
let url = URL.createObjectURL(blob)
this.displayImg.src = url
})
}
render() {
this.changeColor(this.color)
return html`
<div id="image-container">
<div id="spinner"></div>
<img id="display" />
</div>
<div>
<div dir="auto" id="subtitle">${this.name}</div>
</div>
`
}
}
declare global {
interface HTMLElementTagNameMap {
'site-icon': SiteIcon
}
}

View File

@ -17,8 +17,10 @@ import './container/container'
import './radio/radio'
import './switch/switch'
import './slider/slider'
import './container/homescreen-container'
import './toast/toast'
import './picker/picker'
type SEID = string
@customElement('panel-root')
@ -163,6 +165,14 @@ export class PanelRoot extends LitElement {
href="#blur"
></star-li>
<hr />
<star-li
type=${LiType.ICON_LABEL}
label="主屏"
icon="dismiss-keyboard"
iconcolor="#eee"
href="#homescreen"
></star-li>
<hr />
<star-li
type=${LiType.ICON_LABEL}
label="基础卡片组件"

View File

@ -0,0 +1,102 @@
class SlotStyleHandler {
regex: {[regStr: string]: RegExp} = {
slottedCss: /(?:\:\:slotted\(.*\))[^{]+\{[^}]*\}/g,
'::slotted()': /\:\:slotted\(([^\(]+)\)/g,
keyframes: /@keyframes[^{]+\{([^{]+\{[^}]*\})*\D*\}/g,
}
head!: HTMLElement
headFirstElement!: ChildNode | null
headStyle!: HTMLElement
shouldRefresh: boolean = false
styles: {[styleName: string]: string} = new Proxy(
{},
{
set: (
target: {[styleName: string]: string},
prop: string,
value: string
) => {
if (!target[prop] || target[prop] !== value) {
target[prop] = value
this.shouldRefresh = true
}
return true
},
}
)
processCss(style: string, name: string) {
let globalCss = ''
style.replace(this.regex.keyframes, (match) => {
globalCss += match
return ''
})
style = style.replace(this.regex.slottedCss, (match) => {
globalCss += match.replace(this.regex['::slotted()'], name + ' $1')
return ''
})
return globalCss
}
/**
* CSS注入头部<style></style>
*
* @param {String} style
* @param {String} name
* @returns
*/
injectGlobalCss(
component: HTMLElement,
style: string,
name: string,
subName = ''
) {
if (!style) return
const styleName = subName ? `${name}-${subName}` : name
let css = this.processCss(style, name)
this.styles[styleName] = css
if (this.shouldRefresh) {
if (!this.head || !this.headFirstElement) {
this.head = document.head
this.headFirstElement = this.head.firstChild
}
if (!this.headStyle) {
this.headStyle = document.createElement('style')
if (this.headFirstElement) {
this.head.insertBefore(this.headStyle, this.headFirstElement)
} else {
this.head.appendChild(this.headStyle)
}
}
// 当父节点也是 Web Component时防止全局注册的 Style 被父节点的
// ShadowRoot 隔离,需要再在父节点中插入一份样式
if (
component.parentNode &&
(Object.prototype.toString
.call(component.parentNode)
.includes('DocumentFragment') ||
Object.prototype.toString
.call(component.parentNode)
.includes('ShadowRoot'))
) {
let scoped = document.createElement('style')
scoped.innerHTML = css.trim()
component.parentNode.appendChild(scoped)
}
let style = ''
for (const styleName in this.styles) {
const content = this.styles[styleName]
style += content
}
this.headStyle.innerHTML = style
this.shouldRefresh = false
}
}
}
export default new SlotStyleHandler()