From e9db66532c771870414d62a704520cbf469fa6ab Mon Sep 17 00:00:00 2001 From: zhouxinyu Date: Tue, 11 Feb 2025 21:05:42 +0800 Subject: [PATCH] feat: support grid layout --- .../src/character/chart/character-chart.ts | 2 +- .../character/common/runtime/common-layout.ts | 2 +- .../src/character/component/runtime/base.ts | 4 +- .../src/character/component/runtime/text.ts | 4 +- .../src/character/table/character-table.ts | 2 +- packages/vstory-core/src/core/story.ts | 25 +++- packages/vstory-core/src/interface/dsl/dsl.ts | 59 +++++++-- packages/vstory-core/src/interface/story.ts | 3 + packages/vstory-core/src/utils/layout.ts | 53 +++++--- packages/vstory/demo/src/App.tsx | 10 ++ .../vstory/demo/src/demos/layout/grid.tsx | 123 ++++++++++++++++++ 11 files changed, 249 insertions(+), 38 deletions(-) create mode 100644 packages/vstory/demo/src/demos/layout/grid.tsx diff --git a/packages/vstory-core/src/character/chart/character-chart.ts b/packages/vstory-core/src/character/chart/character-chart.ts index 0e6268a0..d376fb98 100644 --- a/packages/vstory-core/src/character/chart/character-chart.ts +++ b/packages/vstory-core/src/character/chart/character-chart.ts @@ -191,7 +191,7 @@ export class CharacterChart } protected getViewBoxFromSpec() { - const layout = getLayoutFromWidget(this._config.position, this); + const layout = getLayoutFromWidget(this._config, this); const viewBox = { x1: layout.x, x2: layout.x + layout.width, diff --git a/packages/vstory-core/src/character/common/runtime/common-layout.ts b/packages/vstory-core/src/character/common/runtime/common-layout.ts index 7575b784..0771ad96 100644 --- a/packages/vstory-core/src/character/common/runtime/common-layout.ts +++ b/packages/vstory-core/src/character/common/runtime/common-layout.ts @@ -8,7 +8,7 @@ export class CommonLayoutRuntime implements IChartCharacterRuntime { applyConfigToAttribute(character: ICharacterChart): void { const rawAttribute = character.getRuntimeConfig().getAttribute(); const config = character.getRuntimeConfig().config; - const layoutData = getLayoutFromWidget(config.position, character); + const layoutData = getLayoutFromWidget(config, character); const viewBox = { x1: 0, x2: layoutData.width, diff --git a/packages/vstory-core/src/character/component/runtime/base.ts b/packages/vstory-core/src/character/component/runtime/base.ts index 365cd529..959e1291 100644 --- a/packages/vstory-core/src/character/component/runtime/base.ts +++ b/packages/vstory-core/src/character/component/runtime/base.ts @@ -8,8 +8,8 @@ export class BaseGraphicRuntime implements IComponentCharacterRuntime { applyConfigToAttribute(character: ICharacterComponent): void { const rawAttribute = character.getAttribute(); - const { options, position, locked } = character.config; - const layout = getLayoutFromWidget(position, character); + const { options, locked } = character.config; + const layout = getLayoutFromWidget(character.config, character); const { graphic = {}, text = {}, panel = {}, padding } = options; diff --git a/packages/vstory-core/src/character/component/runtime/text.ts b/packages/vstory-core/src/character/component/runtime/text.ts index 4f7ec37c..12c4ac8f 100644 --- a/packages/vstory-core/src/character/component/runtime/text.ts +++ b/packages/vstory-core/src/character/component/runtime/text.ts @@ -8,8 +8,8 @@ export class TextRuntime implements IComponentCharacterRuntime { applyConfigToAttribute(character: ICharacterComponent): void { const rawAttribute = character.getAttribute(); - const { options, position } = character.config; - const layout = getLayoutFromWidget(position, character); + const { options } = character.config; + const layout = getLayoutFromWidget(character.config, character); const { graphic = {}, panel = {}, padding } = options; diff --git a/packages/vstory-core/src/character/table/character-table.ts b/packages/vstory-core/src/character/table/character-table.ts index 65ea9ebd..fe5470fb 100644 --- a/packages/vstory-core/src/character/table/character-table.ts +++ b/packages/vstory-core/src/character/table/character-table.ts @@ -154,7 +154,7 @@ export class CharacterTable } protected getViewBoxFromSpec() { - const layout = getLayoutFromWidget(this._config.position, this); + const layout = getLayoutFromWidget(this._config, this); const viewBox = { x1: layout.x, x2: layout.x + layout.width, diff --git a/packages/vstory-core/src/core/story.ts b/packages/vstory-core/src/core/story.ts index 99eff0ae..defdb980 100644 --- a/packages/vstory-core/src/core/story.ts +++ b/packages/vstory-core/src/core/story.ts @@ -27,6 +27,7 @@ export interface IStoryInitOption { scaleX?: number | 'auto'; scaleY?: number | 'auto'; theme?: string; + dslOptions?: Omit; } export class Story extends EventEmitter implements IStory { @@ -36,6 +37,7 @@ export class Story extends EventEmitter implements IStory { protected _player: IPlayer; protected _characterTree: ICharacterTree; protected _theme: string; + protected _dslOptions: Omit; pluginService: IPluginService; get canvas(): IStoryCanvas { @@ -50,6 +52,10 @@ export class Story extends EventEmitter implements IStory { return this._theme; } + get dslOptions(): Omit { + return this._dslOptions; + } + constructor(dsl: IStoryDSL | null, option: IStoryInitOption) { super(); this.id = `test-mvp_${Generator.GenAutoIncrementId()}`; @@ -64,7 +70,8 @@ export class Story extends EventEmitter implements IStory { layerViewBox, dpr = vglobal.devicePixelRatio, scaleX = 1, - scaleY = 1 + scaleY = 1, + dslOptions = { version: '0.0.2', width: option.width, height: option.height } } = option; if (!(dom || canvas)) { throw new Error('dom or canvas is required'); @@ -83,11 +90,26 @@ export class Story extends EventEmitter implements IStory { }); this._characterTree = new CharacterTree(this); this._dsl = dsl; + if (dsl) { + const options = { ...dsl }; + delete options.characters; + delete options.acts; + this._dslOptions = options; + } else { + this._dslOptions = dslOptions; + } this._theme = theme; this.pluginService = new DefaultPluginService(); this.pluginService.active(this, { pluginList: [] }); + + // TODO 兼容历史版本,后续版本删除 + if (!(this._dslOptions.width && this._dslOptions.height)) { + console.warn('width and height is required in dslOptions'); + } + this._dslOptions.width = this._dslOptions.width ?? this._canvas.getStage().width; + this._dslOptions.height = this._dslOptions.height ?? this._canvas.getStage().height; } init(player: IPlayer) { @@ -112,6 +134,7 @@ export class Story extends EventEmitter implements IStory { } toDSL(): IStoryDSL { return { + ...this._dslOptions, acts: this._player.toDSL(), characters: this._characterTree.toDSL() }; diff --git a/packages/vstory-core/src/interface/dsl/dsl.ts b/packages/vstory-core/src/interface/dsl/dsl.ts index 527b71e9..056b3e37 100644 --- a/packages/vstory-core/src/interface/dsl/dsl.ts +++ b/packages/vstory-core/src/interface/dsl/dsl.ts @@ -33,22 +33,20 @@ export interface IActionPayload { export type IActionSpec = IAction; export type IWidgetData = { + // 网格布局定位 + columnSpan?: [number, number]; + rowSpan?: [number, number]; + left?: number; top?: number; - x?: number; - y?: number; + bottom?: number; + right?: number; + width?: number; + height?: number; + angle?: number; anchor?: [number, number]; -} & ( - | { - bottom?: number; - right?: number; - } - | { - width?: number; - height?: number; - } -); +}; export interface IActSpec { id: string; @@ -69,6 +67,21 @@ export type ISceneSpec = { export interface ICharacterConfigBase { id: string; type: string; // 类型 + layoutType?: 'absolute' | 'grid' | 'flex'; // 布局类型 + // flex布局配置 + flexConfig?: { + direction: 'row' | 'column'; + wrap: 'wrap' | 'nowrap'; + justifyContent: 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around'; + alignItems: 'flex-start' | 'flex-end' | 'center'; + }; + // 网格布局配置 + gridConfig?: { + columns: number; + rows: number; + gutterColumn: number; + gutterRow: number; + }; position: IWidgetData; // 定位描述 zIndex: number; theme?: string; @@ -76,6 +89,12 @@ export interface ICharacterConfigBase { locked?: boolean; // 是否锁定 } +// 新增 container 类型的配置接口 +export interface IContainerCharacterConfig extends ICharacterConfigBase { + type: 'container'; + children: ICharacterConfig[]; // 子元素 +} + export type IEditorTextGraphicAttribute = { graphicAlign?: 'left' | 'center' | 'right'; graphicBaseline?: 'top' | 'middle' | 'bottom'; @@ -85,7 +104,8 @@ export type ICharacterConfig = | IChartCharacterConfig | IComponentCharacterConfig | ITableCharacterConfig - | IPivotChartCharacterConfig; + | IPivotChartCharacterConfig + | IContainerCharacterConfig; // 添加 container 类型 export type IUpdateConfigParams = Omit, 'id' | 'type'>; @@ -101,6 +121,17 @@ export interface ICharacterConstructor { } export interface IStoryDSL { - acts: IActSpec[]; // 作品的章节 + version: string; // 版本号 + width: number; + height: number; + theme?: string; // 主题 + background?: string; // 背景色 + gridConfig?: { + columns: number; + rows: number; + gutterColumn: number; + gutterRow: number; + }; characters: ICharacterConfig[]; // 作品中的元素 + acts: IActSpec[]; // 作品的章节 } diff --git a/packages/vstory-core/src/interface/story.ts b/packages/vstory-core/src/interface/story.ts index 8fb27165..2e2f7dc1 100644 --- a/packages/vstory-core/src/interface/story.ts +++ b/packages/vstory-core/src/interface/story.ts @@ -16,6 +16,9 @@ export interface IStory extends IReleaseable, EventEmitter { readonly player: IPlayer; readonly theme: string; + // 忽略IStoryDSL中的characters和actions + readonly dslOptions: Omit; + load: (dsl: IStoryDSL) => void; reset: () => void; toDSL: () => IStoryDSL; diff --git a/packages/vstory-core/src/utils/layout.ts b/packages/vstory-core/src/utils/layout.ts index 1069172f..359c3d16 100644 --- a/packages/vstory-core/src/utils/layout.ts +++ b/packages/vstory-core/src/utils/layout.ts @@ -1,5 +1,4 @@ -import type { IRect } from '@visactor/vrender-core'; -import type { IWidgetData } from '../interface/dsl/dsl'; +import type { ICharacterConfigBase } from '../interface/dsl/dsl'; import type { ICharacter, ILayoutLine } from '../interface/character'; import type { IAABBBounds } from '@visactor/vutils'; @@ -15,27 +14,49 @@ export interface ILayoutAttribute { // shapePoints?: IPointLike[]; } -export function getLayoutFromWidget(w: Partial | IRect, character: ICharacter): Partial { - const x = 'x' in w ? w.x : w.left; - const y = 'y' in w ? w.y : w.top; - let width = (w as any).width; - let height = (w as any).height; - const stage = character.canvas.getStage(); - if (!isFinite(width) && isFinite((w as any).right)) { - width = stage.width - x - (w as any).right; - } - if (!isFinite(height) && isFinite((w as any).bottom)) { - height = stage.height - y - (w as any).bottom; +export function getLayoutFromWidget(config: ICharacterConfigBase, character: ICharacter): Partial { + const { position = {} } = config; + let x: number; + let y: number; + let width: number; + let height: number; + const { gridConfig, width: boxWidth, height: boxHeight } = character.story.dslOptions; + + const { columnSpan, rowSpan } = position; + if (position.columnSpan && position.rowSpan && position.columnSpan.length === 2 && position.rowSpan.length === 2) { + const { columns, rows, gutterColumn, gutterRow } = gridConfig; + const columnWidth = (boxWidth - (columns - 1) * gutterColumn) / columns; + const rowHeight = (boxHeight - (rows - 1) * gutterRow) / rows; + x = columnSpan[0] * columnWidth + columnSpan[0] * gutterColumn; + y = rowSpan[0] * rowHeight + rowSpan[0] * gutterRow; + const deltaColumn = columnSpan[1] - columnSpan[0]; + const deltaRow = rowSpan[1] - rowSpan[0]; + width = deltaColumn * columnWidth + Math.max(0, deltaColumn - 1) * gutterColumn; + height = deltaRow * rowHeight + Math.max(0, deltaRow - 1) * gutterRow; + } else { + // 兼容旧版的xy + if ((position as any).x || (position as any).y) { + console.warn('布局将不再支持x、y属性,请尽快改为使用left、top属性'); + } + x = position.left ?? (position as any).x; + y = position.top ?? (position as any).y; + width = position.width; + height = position.height; + + if (!isFinite(width) && isFinite(position.right)) { + width = boxWidth - x - position.right; + } + if (!isFinite(height) && isFinite(position.bottom)) { + height = boxHeight - y - position.bottom; + } } - // const width = 'width' in w ? w.width : (w as any).right - w.left; - // const height = 'height' in w ? w.height : (w as any).bottom - w.top; return { x, y, width: isFinite(width) ? width : void 0, height: isFinite(height) ? height : void 0, - angle: (w as any).angle ?? 0, + angle: position.angle ?? 0, anchor: [x + width / 2, y + height / 2].map(item => (isFinite(item) ? item : 0)) as [number, number] }; } diff --git a/packages/vstory/demo/src/App.tsx b/packages/vstory/demo/src/App.tsx index 870b5f22..d4409a26 100644 --- a/packages/vstory/demo/src/App.tsx +++ b/packages/vstory/demo/src/App.tsx @@ -70,6 +70,7 @@ import { TableTheme } from './demos/table/runtime/theme'; import { TableStyle } from './demos/table/runtime/style'; import { TableVisible } from './demos/table/runtime/visible'; import { SpecMarker } from './demos/chart/runtime/spec-marker'; +import { LayoutGridComponent } from './demos/layout/grid'; type MenusType = ( | { @@ -259,6 +260,15 @@ const App = () => { } ] }, + { + name: 'Layout', + subMenus: [ + { + name: 'Grid', + component: LayoutGridComponent + } + ] + }, { name: 'Infographic', subMenus: [ diff --git a/packages/vstory/demo/src/demos/layout/grid.tsx b/packages/vstory/demo/src/demos/layout/grid.tsx new file mode 100644 index 00000000..cdf7e056 --- /dev/null +++ b/packages/vstory/demo/src/demos/layout/grid.tsx @@ -0,0 +1,123 @@ +import React, { createRef, useEffect } from 'react'; +import { Player, Story } from '../../../../../vstory-core/src'; +import { registerAllSelection } from '../../../../../vstory-editor/src'; +import { Edit, registerAll } from '../../../../src'; + +registerAll(); +registerAllSelection(); + +export const LayoutGridComponent = () => { + const id = 'LayoutGridComponent'; + useEffect(() => { + const container = document.getElementById(id); + const canvas = document.createElement('canvas'); + container?.appendChild(canvas); + + const dsl = { + acts: [ + { + id: 'defaultAct', + scenes: [ + { + id: 'defaultScene', + actions: [ + { + characterId: new Array(12 * 8).fill(0).map((_, index) => index.toString()), + characterActions: [ + { + startTime: 0, + action: 'appear', + payload: [ + { + animation: { + duration: 1000, + effect: 'wipe', + easing: 'linear' + } as any + } + ] + }, + { + startTime: 1000, + duration: 800, + action: 'style', + payload: { + graphic: { + fontSize: 40 + }, + animation: { + duration: 800 + } + } + } + ] + } + ] + } + ] + } + ], + width: 1024, + height: 768, + gridConfig: { + columns: 12, + rows: 8, + gutterColumn: 20, + gutterRow: 20 + }, + characters: new Array(12 * 8).fill(0).map((_, index) => ({ + type: 'Rect', + id: index.toString(), + zIndex: 1, + position: { + columnSpan: [index % 12, (index % 12) + 1], + rowSpan: [Math.floor(index / 12), Math.floor(index / 12) + 1] + }, + options: { + graphic: { + fill: `rgb(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor( + Math.random() * 256 + )})` + } + } + })) + }; + + const story = new Story(dsl as any, { + canvas, + width: 1024, + height: 768, + // layerBackground: 'pink', + background: 'pink' + // scaleX: 0.5, + // scaleY: 0.5, + // layerViewBox: { x1: 100, y1: 100, x2: 900, y2: 500 } + }); + const player = new Player(story); + story.init(player); + + player.play(-1); + + let selectedCharacter: any = null; + const edit = new Edit(story as any); + edit.on('startEdit', msg => { + selectedCharacter = msg.actionInfo.character; + if (msg.type === 'commonEdit' && msg.actionInfo.character) { + msg.updateCharacter({ options: { graphic: { fill: 'green' } } }); + player.play(); + } + }); + edit.on('endEdit', msg => { + selectedCharacter = null; + }); + edit.on('resize', msg => { + // console.log('resize', msg); + }); + + return () => { + story.release(); + }; + }, []); + + return
; +};