UNPKG

gem-panel

Version:

A custom element <gem-panel>, let you easily create layout similar to Adobe After Effects.

573 lines (522 loc) 18.7 kB
import { randomStr } from '@mantou/gem'; import { MoveSideArgs, Side } from '../elements/window-handle'; import { Panel } from './panel'; import { WINDOW_DEFAULT_DIMENSION, WINDOW_DEFAULT_GAP, WINDOW_DEFAULT_POSITION, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, } from './const'; import { findLimintPosition, getFlipMatrix, getNewFocusElementIndex, isEqualArray, removeItem, swapPosition, } from './utils'; interface WindowOptional { engross?: boolean; gridArea?: string; current?: number; position?: [number, number]; zIndex?: number; dimension?: [number, number]; } export class Window implements WindowOptional { id: string; engross?: boolean; gridArea?: string; current: number; position?: [number, number]; zIndex: number; // No cache dimension?: [number, number]; panels: string[]; static parse({ gridArea, current = 0, panels = [], position, dimension, engross }: Window) { return new Window(panels, { gridArea, current, position, dimension, engross }); } constructor(panels: (string | Panel)[] = [], optional: WindowOptional = {}) { const { gridArea = '', current = 0, position, dimension, zIndex = 1, engross } = optional; this.id = randomStr(); this.zIndex = zIndex + 10; this.current = current; this.gridArea = gridArea; this.engross = engross; if (engross && panels.length > 1) throw new Error('Engross window only allows a panel'); this.panels = [...new Set(panels.map((p) => (typeof p === 'string' ? p : p.name)))]; if (position || dimension) { this.position = position || WINDOW_DEFAULT_POSITION; this.dimension = dimension || WINDOW_DEFAULT_DIMENSION; } } isGridWindow() { return !this.position && !this.dimension; } changeCurrent(index: number) { this.current = index; } changePanelSort(p1: string, p2: string) { const p1Index = this.panels.findIndex((e) => e === p1); const p2Index = this.panels.findIndex((e) => e === p2); [this.panels[p1Index], this.panels[p2Index]] = [p2, p1]; if (this.current === p1Index) { this.changeCurrent(p2Index); } else if (this.current === p2Index) { this.changeCurrent(p1Index); } } setGridArea(area: string) { this.position = undefined; this.dimension = undefined; this.gridArea = area; } } interface LayoutOptional { gridTemplateAreas?: string; gridTemplateRows?: string; gridTemplateColumns?: string; } const defaultLayout: Required<LayoutOptional>[] = [ { gridTemplateAreas: `"a"`, gridTemplateRows: '1fr', gridTemplateColumns: '1fr', }, { gridTemplateAreas: `"a b"`, gridTemplateRows: '1fr', gridTemplateColumns: '1fr 1fr', }, { gridTemplateAreas: ` "a b" "a c" `, gridTemplateRows: '1fr 1fr', gridTemplateColumns: '1fr 1fr', }, { gridTemplateAreas: ` "a b" "c d" `, gridTemplateRows: '1fr 1fr', gridTemplateColumns: '1fr 1fr', }, { gridTemplateAreas: ` "a d" "b d" "b e" "c e" `, gridTemplateRows: '2fr 1fr 1fr 2fr', gridTemplateColumns: '1fr 1fr', }, { gridTemplateAreas: ` "a b" "c d" "e f" `, gridTemplateRows: '1fr 1fr 1fr', gridTemplateColumns: '1fr 1fr', }, { gridTemplateAreas: ` "a d" "a e" "b e" "b f" "c f" "c g" `, gridTemplateRows: '3fr 1fr 2fr 2fr 1fr 3fr', gridTemplateColumns: '1fr 1fr', }, ]; export class Layout implements LayoutOptional { gridTemplateAreas?: string; gridTemplateRows?: string; gridTemplateColumns?: string; windows: Window[]; #areas: string[][]; // Optimization: Flip the object to modify it #rows: number[]; #columns: number[]; #findAreas = (window: Window | string) => { const areaName = typeof window === 'string' ? window : window.gridArea; const areas: [number, number][] = []; this.#areas.forEach((row, y) => { row.forEach((area, x) => { if (area === areaName) { areas.push([x, y]); } }); }); return areas; }; #findAreasBoundary = (areas: [number, number][]) => { const rows = [...new Set(areas.map((area) => area[1]))]; const height = rows.map((rowIndex) => this.#rows[rowIndex]).reduce((p, c) => p + c, 0); const minRow = Math.min(...rows); const maxRow = Math.max(...rows); const columns = [...new Set(areas.map((area) => area[0]))]; const width = columns.map((columnIndex) => this.#columns[columnIndex]).reduce((p, c) => p + c, 0); const minColumn = Math.min(...columns); const maxColumn = Math.max(...columns); return { minRow, maxRow, minColumn, maxColumn, rows, columns, height, width }; }; #parseAreas = (gridTemplateAreas: string) => { this.#areas = gridTemplateAreas .split(/\s*["'\n]/) .filter((e) => e.trim() !== '') .map((e) => e.split(/\s+/)); }; #stringifyGridTemplateAreas = () => { this.gridTemplateAreas = this.#areas.map((row) => `"${row.join(' ')}"`).join(' '); }; #stringifyGridTemplate = () => { this.#stringifyGridTemplateAreas(); this.#stringifyGridTemplateRows(); this.#stringifyGridTemplateColumns(); }; #optimizationAreas = () => { const optimizationRow = () => { for (let i = 0; i < this.#areas.length - 1; i++) { if (isEqualArray(this.#areas[i], this.#areas[i + 1])) { this.#areas.splice(i, 1); const deleteRows = this.#rows.splice(i, 2); const mergeRow = deleteRows.reduce((p, c) => p + c); this.#rows.splice(i, 0, mergeRow); optimizationRow(); break; } } }; optimizationRow(); const optimizationColumn = () => { for (let i = 0; i < this.#areas[0].length - 1; i++) { const currentCol = this.#areas.map((row) => row[i]); const nextCol = this.#areas.map((row) => row[i + 1]); if (isEqualArray(currentCol, nextCol)) { this.#areas.forEach((row) => row.splice(i, 1)); const deleteColumns = this.#columns.splice(i, 2); const mergeColumn = deleteColumns.reduce((p, c) => p + c); this.#columns.splice(i, 0, mergeColumn); break; } } }; optimizationColumn(); this.#stringifyGridTemplate(); }; #parseRows = (gridTemplateRows: string) => { this.#rows = this.#parseGridTemplate(gridTemplateRows); }; #parseColumns = (gridTemplateColumns: string) => { this.#columns = this.#parseGridTemplate(gridTemplateColumns); }; #parseGridTemplate = (gridTemplate: string) => gridTemplate .split(/\s+/) .filter((e) => e !== '') .map(parseFloat); #stringifyGridTemplateAxis = (arr: number[]) => arr.map((e) => `${e}fr`).join(' '); #stringifyGridTemplateRows = () => { this.gridTemplateRows = this.#stringifyGridTemplateAxis(this.#rows); }; #stringifyGridTemplateColumns = () => { this.gridTemplateColumns = this.#stringifyGridTemplateAxis(this.#columns); }; #getNewGridArea = () => `a${randomStr()}${Layout.id++}`; static parse(str: string) { const obj = JSON.parse(str) as Partial<Layout> | null; if (!obj) return; const { gridTemplateAreas, gridTemplateRows, gridTemplateColumns, windows = [] } = obj; return new Layout(windows.map(Window.parse), { gridTemplateAreas, gridTemplateRows, gridTemplateColumns, }); } static id = 1; constructor(allWindows: Window[] = [], optional: LayoutOptional = {}) { const { gridTemplateAreas, gridTemplateRows, gridTemplateColumns } = optional; const windows = allWindows.filter((w) => w.isGridWindow()); const dl = defaultLayout[windows.length - 1] || defaultLayout[0]; this.gridTemplateAreas = gridTemplateAreas || dl.gridTemplateAreas; this.#parseAreas(this.gridTemplateAreas); this.gridTemplateRows = gridTemplateRows || dl.gridTemplateRows; this.#parseRows(this.gridTemplateRows); this.gridTemplateColumns = gridTemplateColumns || dl.gridTemplateColumns; this.#parseColumns(this.gridTemplateColumns); windows.forEach((w, i) => { if (!w.gridArea) { w.gridArea = [...new Set(this.#areas.flat())][i]; } }); this.windows = allWindows; } moveWindow(window: Window, [x, y]: [number, number]) { const [originX = 0, originY = 0] = window.position || []; window.position = [Math.max(originX + x, 0), Math.max(originY + y, 0)]; } changeWindowRect(window: Window, [mx, my, mw, mh]: [number, number, number, number]) { const [originX = 0, originY = 0] = window.position || []; const [originW = 0, originH = 0] = window.dimension || []; const w = originW + mw; const h = originH + mh; const x = w < WINDOW_MIN_WIDTH ? originX : originX + mx; const y = h < WINDOW_MIN_HEIGHT ? originY : originY + my; window.position = [Math.max(x, 0), Math.max(y, 0)]; window.dimension = [w < WINDOW_MIN_WIDTH || x < 0 ? originW : w, h < WINDOW_MIN_HEIGHT || y < 0 ? originH : h]; } activePanel(window: Window, panelName: string) { window.changeCurrent(window.panels.findIndex((p) => p === panelName)); } focusWindow(window: Window) { if (window.isGridWindow()) return false; const maxZIndex = Math.max(...this.windows.filter((w) => w !== window).map((w) => w.zIndex)); if (window.zIndex > maxZIndex) return false; window.zIndex = maxZIndex + 1; return true; } removeWindow(window: Window, newWindowRect?: [number, number, number, number]) { if (newWindowRect) { const [x, y, w, h] = newWindowRect; window.position = [x, y]; window.dimension = [w, h]; this.focusWindow(window); } else { removeItem(this.windows, window); } const areas = this.#findAreas(window); const { minRow, maxRow, minColumn, maxColumn, rows, columns } = this.#findAreasBoundary(areas); const topAreas = [...new Set(columns.map((column) => this.#areas[minRow - 1]?.[column]).filter((e) => !!e))]; const leftAreas = [...new Set(rows.map((row) => this.#areas[row][minColumn - 1]).filter((e) => !!e))]; const rightAreas = [...new Set(rows.map((row) => this.#areas[row][maxColumn + 1]).filter((e) => !!e))]; const bottomAreas = [...new Set(columns.map((column) => this.#areas[maxRow + 1]?.[column]).filter((e) => !!e))]; const predicateCol = (area: string) => { const areas = this.#findAreas(area); const boundary = this.#findAreasBoundary(areas); return boundary.minColumn >= minColumn && boundary.maxColumn <= maxColumn; }; const predicateRow = (area: string) => { const areas = this.#findAreas(area); const boundary = this.#findAreasBoundary(areas); return boundary.minRow >= minRow && boundary.maxRow <= maxRow; }; if (topAreas.length && topAreas.every(predicateCol)) { areas.forEach(([x, y]) => { this.#areas[y][x] = this.#areas[minRow - 1][x]; }); } else if (leftAreas.length && leftAreas.every(predicateRow)) { areas.forEach(([x, y]) => { this.#areas[y][x] = this.#areas[y][minColumn - 1]; }); } else if (rightAreas.length && rightAreas.every(predicateRow)) { areas.forEach(([x, y]) => { this.#areas[y][x] = this.#areas[y][maxColumn + 1]; }); } else if (bottomAreas.length && bottomAreas.every(predicateCol)) { areas.forEach(([x, y]) => { this.#areas[y][x] = this.#areas[maxRow + 1][x]; }); } this.#optimizationAreas(); } mergeWindow(window: Window, target: Window) { swapPosition(this.windows, window, target); [target.id, window.id] = [window.id, target.id]; removeItem(this.windows, window); const targetLen = target.panels.length; target.panels = [...new Set([...target.panels, ...window.panels])]; target.changeCurrent(targetLen + window.current); this.focusWindow(target); } convertGridWindow(window: Window, hoverWindow: Window, side: Side) { const areas = this.#findAreas(hoverWindow); const { rows, columns, width, height } = this.#findAreasBoundary(areas); const gridArea = this.#getNewGridArea(); if (side === 'top' || side === 'bottom') { const heightRows = rows.map((rowIndex) => this.#rows[rowIndex]); const { index, margin } = findLimintPosition(heightRows, height / 2); const limitRowIndex = rows[index]; this.#areas.splice(limitRowIndex, 0, [...this.#areas[limitRowIndex]]); columns.forEach((columnIndex) => { rows.forEach((rowIndex, i) => { if (side === 'top') { if (i <= index) { this.#areas[rowIndex][columnIndex] = gridArea; } } else { if (i >= index) { this.#areas[rowIndex + 1][columnIndex] = gridArea; } } }); }); this.#rows.splice(limitRowIndex, 1, this.#rows[limitRowIndex] - margin, margin); } if (side === 'right' || side === 'left') { const widthColumns = columns.map((columnIndex) => this.#columns[columnIndex]); const { index, margin } = findLimintPosition(widthColumns, width / 2); const limitColumnIndex = columns[index]; this.#areas.forEach((row) => row.splice(limitColumnIndex, 0, row[limitColumnIndex])); rows.forEach((rowIndex) => { this.#areas[rowIndex][side === 'left' ? limitColumnIndex : limitColumnIndex + 1] = gridArea; columns.forEach((columnIndex, i) => { if (side === 'left') { if (i <= index) { this.#areas[rowIndex][columnIndex] = gridArea; } } else { if (i >= index) { this.#areas[rowIndex][columnIndex + 1] = gridArea; } } }); }); this.#columns.splice(limitColumnIndex, 1, this.#columns[limitColumnIndex] - margin, margin); } window.setGridArea(gridArea); this.#stringifyGridTemplate(); } createIndependentWindow(window: Window | null, panelName: string, [x, y, w, h]: [number, number, number, number]) { const newWindow = new Window([panelName], { position: [x, y], dimension: [w, h] }); this.focusWindow(newWindow); this.windows.push(newWindow); if (window) { if (panelName === window.panels[window.current]) { // `repeat` 在 chrome 中不能复用元素,所以手动调整位置 swapPosition(this.windows, window, newWindow); [newWindow.id, window.id] = [window.id, newWindow.id]; } this.closePanel(window, panelName); } return newWindow; } openHiddenPanel(panelName: string) { const getPosition = (position: [number, number]): [number, number] => { const window = this.windows.find((w) => w.position && isEqualArray(w.position, position)); return window ? getPosition([position[0] + WINDOW_DEFAULT_GAP, position[1] + WINDOW_DEFAULT_GAP]) : position; }; const newWindow = this.createIndependentWindow(null, panelName, [ ...getPosition(WINDOW_DEFAULT_POSITION), ...WINDOW_DEFAULT_DIMENSION, ]); this.focusWindow(newWindow); } openPanelInWindow(window: Window, panelName: string, side?: Side) { if (side) { if (window.isGridWindow()) { const newWindow = new Window([panelName]); this.windows.push(newWindow); this.convertGridWindow(newWindow, window, side); } else { this.openHiddenPanel(panelName); } return; } else { if (window.engross) return; window.changeCurrent(window.panels.push(panelName) - 1); this.focusWindow(window); } } closePanel(window: Window, panelName: string) { if (window.engross) return; const panelIndex = window.panels.findIndex((e) => e === panelName); const closerIndex = getNewFocusElementIndex(window.panels, window.current, panelIndex); window.panels.splice(panelIndex, 1); if (closerIndex >= 0) { window.changeCurrent(closerIndex); } else { this.removeWindow(window); } } moveSide(window: Window, side: Side, args: MoveSideArgs) { const { minRow, minColumn, maxRow, maxColumn, width, height } = this.#findAreasBoundary(this.#findAreas(window)); const move = ( rowsFr: number[], areas: string[][], minRowIndex: number, maxRowIndex: number, minColumnIndex: number, maxColumnIndex: number, movementYPx: number, heightPx: number, heightFr: number, maxHeightPx: number, ) => { const movement = (movementYPx / heightPx) * heightFr; const gap = (args.gap / heightPx) * heightFr; const index = side === 'top' || side === 'left' ? minRowIndex : maxRowIndex + 1; const shrinked = rowsFr[index - 1] + movement; const growed = rowsFr[index] - movement; const unit = heightPx / heightFr; const checkIndex = movementYPx > 0 ? index : index - 1; const area = [...new Set(areas[checkIndex])]; for (let i = 0; i < area.length; i++) { const siblingHeightPx = this.#findAreasBoundary(this.#findAreas(area[i]))[side === 'top' || side === 'bottom' ? 'height' : 'width'] * unit; if (siblingHeightPx < maxHeightPx) return; } if (shrinked < 0 || growed < 0) { let small = 0; let big = 0; let r1 = 0; let r2 = 0; let r3 = 0; if (shrinked < 0) { [small, big, r3, r2, r1] = [shrinked, growed, index - 2, index - 1, index]; } else if (growed < 0) { [small, big, r3, r2, r1] = [growed, shrinked, index + 1, index, index - 1]; } if (-small - gap < 0) return; rowsFr[r1] = big + small + gap; rowsFr[r2] = -small - gap; rowsFr[r3] += small; areas[r2].forEach((_, columnIndex) => { if (columnIndex < minColumnIndex || columnIndex > maxColumnIndex) { areas[r2][columnIndex] = areas[r3][columnIndex]; } else { areas[r2][columnIndex] = areas[r1][columnIndex]; } }); } else { rowsFr[index - 1] = shrinked; rowsFr[index] = growed; } }; if (side === 'top' || side === 'bottom') { move( this.#rows, this.#areas, minRow, maxRow, minColumn, maxColumn, args.movementY, args.height, height, WINDOW_MIN_HEIGHT, ); } else { move( this.#columns, getFlipMatrix(this.#areas), minColumn, maxColumn, minRow, maxRow, args.movementX, args.width, width, WINDOW_MIN_WIDTH, ); } this.#stringifyGridTemplate(); } }