From 029df2b822fb287f7198e4d13088331b1aee5d37 Mon Sep 17 00:00:00 2001 From: F-star Date: Mon, 29 Apr 2024 12:50:48 +0800 Subject: [PATCH] feat: pixi.js --- .github/workflows/pixi-renderer.yml | 49 ++++ package.json | 4 +- packages/common/src/common.ts | 3 + packages/core/src/commands/remove_graphs.ts | 20 ++ .../control_handle_manager.ts | 21 +- packages/core/src/editor.ts | 39 ++- packages/core/src/graphs/ellipse.ts | 184 +++++++++++--- packages/core/src/graphs/graph/graph.ts | 37 ++- packages/core/src/graphs/line.ts | 23 ++ packages/core/src/graphs/path/path.ts | 120 ++++++++- packages/core/src/graphs/rect.ts | 86 ++++++- packages/core/src/graphs/text.ts | 43 ++++ packages/core/src/grid.ts | 78 ++++-- packages/core/src/path_editor/path_editor.ts | 9 +- packages/core/src/scene/scene_graph.ts | 231 +++++++++--------- packages/core/src/selected_box.ts | 63 +++-- packages/core/src/selection.ts | 84 +++++++ .../src/service/mutate_graphs_and_record.ts | 8 +- packages/core/src/setting.ts | 6 +- packages/core/src/stage_manger.ts | 105 ++++++++ packages/core/src/tools/tool_draw_graph.ts | 2 +- .../tool_path_select_selection.ts | 21 +- .../src/tools/tool_select/tool_select_move.ts | 5 + .../tools/tool_select/tool_select_resize.ts | 1 + .../tools/tool_select/tool_select_rotation.ts | 2 + .../tool_select/tool_select_selection.ts | 16 +- packages/core/src/viewport_manager.ts | 59 +++-- packages/geo/package.json | 2 +- .../components/Cards/FillCard/FillCard.tsx | 12 +- .../Cards/StrokeCard/StrokeCard.tsx | 16 +- packages/suika/src/components/Editor.tsx | 2 + .../components/ZoomActions/ZoomActions.tsx | 1 + pnpm-lock.yaml | 15 +- 33 files changed, 1109 insertions(+), 258 deletions(-) create mode 100644 .github/workflows/pixi-renderer.yml create mode 100644 packages/core/src/selection.ts create mode 100644 packages/core/src/stage_manger.ts diff --git a/.github/workflows/pixi-renderer.yml b/.github/workflows/pixi-renderer.yml new file mode 100644 index 00000000..c7a81802 --- /dev/null +++ b/.github/workflows/pixi-renderer.yml @@ -0,0 +1,49 @@ +# This is a basic workflow to help you get started with Actions + +name: CI + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the master branch + push: + branches: [feat/repalce-canvas2d-to-pixi] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + + # 安装依赖 + - name: Install Dep + run: | + sudo apt-get install snapd + sudo snap install node --classic --channel=20 + npm i -g pnpm@9.0.2 + echo 'pnpm version:' + pnpm -v + pnpm install + # 构建 html + - name: build static files + # Treating warnings as errors because process.env.CI = true. + # 暂时不处理 eslint 的 warn,改成 false + run: pnpm run all:build + + # 部署到自己的服务器 + - name: deploy file to server + uses: wlixcc/SFTP-Deploy-Action@v1.0 + with: + username: ${{ secrets.USERNAME }} + server: ${{ secrets.REMOTE_IP }} + ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} + local_path: './packages/suika/build/*' + remote_path: 'www/app/suika-pixi' diff --git a/package.json b/package.json index 6eea77ec..e72f41de 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,6 @@ "core:dev": "pnpm -F @suika/core dev", "icons:dev": "pnpm -F @suika/icons dev", "components:dev": "pnpm -F @suika/components dev", - "test": "react-scripts test", - "eject": "react-scripts eject", "prepare": "husky install", "eslint:check": "eslint packages" }, @@ -46,7 +44,7 @@ "typescript": "^5.2.2" }, "dependencies": { - "pixi.js": "^8.0.2", + "pixi.js": "^8.1.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/common/src/common.ts b/packages/common/src/common.ts index 0f06a0b0..1b47f3a6 100644 --- a/packages/common/src/common.ts +++ b/packages/common/src/common.ts @@ -154,6 +154,9 @@ export const getDevicePixelRatio = () => { return window.devicePixelRatio || 1; }; +/** + * fill image with "cover" strategy + */ export const calcCoverScale = ( w: number, h: number, diff --git a/packages/core/src/commands/remove_graphs.ts b/packages/core/src/commands/remove_graphs.ts index f18bf944..5384ec72 100644 --- a/packages/core/src/commands/remove_graphs.ts +++ b/packages/core/src/commands/remove_graphs.ts @@ -29,6 +29,18 @@ export class RemoveGraphsCmd implements ICommand { nextElements.push(element); } } + + console.log('removedIndexes', this.removedIndexes); + for (let i = this.removedIndexes.length - 1; i >= 0; i--) { + const index = this.removedIndexes[i]; + const graphics = elements[index].getGraphics(); + if (graphics) { + graphics.removeFromParent(); + } else { + console.warn('graphics is empty'); + } + } + sceneGraph.children = nextElements; this.editor.selectedElements.clear(); @@ -45,6 +57,7 @@ export class RemoveGraphsCmd implements ICommand { const nextElements: Graph[] = new Array( elements.length + removedIndexes.length, ); + const parent = this.editor.stageManager.getScene(); let i = 0; // nextElements 的指针 let j = 0; // elements @@ -52,6 +65,13 @@ export class RemoveGraphsCmd implements ICommand { while (i < nextElements.length) { if (i === removedIndexes[k]) { nextElements[i] = removedElements[k]; + const graphics = removedElements[k].getGraphics(); + if (graphics) { + parent.addChildAt(graphics, i); + } else { + console.warn('graphics is empty'); + } + k++; } else { nextElements[i] = elements[j]; diff --git a/packages/core/src/control_handle_manager/control_handle_manager.ts b/packages/core/src/control_handle_manager/control_handle_manager.ts index 40c2ac65..528f4377 100644 --- a/packages/core/src/control_handle_manager/control_handle_manager.ts +++ b/packages/core/src/control_handle_manager/control_handle_manager.ts @@ -7,7 +7,7 @@ import { rectToMidPoints, rectToVertices, } from '@suika/geo'; -import { Matrix } from 'pixi.js'; +import { Container, Matrix } from 'pixi.js'; import { HALF_PI } from '../constant'; import { type ICursor } from '../cursor_manager'; @@ -36,6 +36,7 @@ const types = [ * Control Point Handle */ export class ControlHandleManager { + private container = new Container(); private transformHandles: Map; private customHandlesVisible = false; @@ -53,6 +54,12 @@ export class ControlHandleManager { }); } + getGraphics() { + return this.container; + } + + // 绘制时机。selected 有东西。 + private onHoverItemChange = () => { if (!this.editor.pathEditor.isActive()) { const hoverItem = this.editor.selectedElements.getHoverItem(); @@ -189,7 +196,13 @@ export class ControlHandleManager { s.rotation = heightRotate; } + clear() { + this.container.removeChildren(); + } + draw(rect: ITransformRect | null) { + this.container.removeChildren(); + this.selectedBoxRect = rect; if (rect) { this.updateTransformHandles(rect); @@ -204,6 +217,7 @@ export class ControlHandleManager { const ctx = this.editor.ctx; const rotate = rect ? getTransformAngle(rect.transform) : 0; + handles.forEach((handle) => { const graph = handle.graph; if (graph.type === GraphType.Path) { @@ -234,9 +248,10 @@ export class ControlHandleManager { if (!graph.getVisible()) { return; } - ctx.save(); + // ctx.save(); graph.draw(ctx); - ctx.restore(); + this.container.addChild(graph.getGraphics()!); + // ctx.restore(); }); } diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index e08d71ca..f838bddc 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -9,6 +9,7 @@ import { ClipboardManager } from './clipboard'; import { CommandManager } from './commands/command_manager'; import { ControlHandleManager } from './control_handle_manager'; import { CursorManger, type ICursor } from './cursor_manager'; +import Grid from './grid'; import { GroupManager } from './group_manager'; import { HostEventManager } from './host_event_manager'; import { ImgManager } from './Img_manager'; @@ -20,7 +21,9 @@ import Ruler from './ruler'; import { SceneGraph } from './scene/scene_graph'; import { SelectedBox } from './selected_box'; import SelectedElements from './selected_elements'; +import { Selection } from './selection'; import { Setting } from './setting'; +import { StageManager } from './stage_manger'; import { AutoSaveGraphs } from './store/auto-save-graphs'; import { TextEditor } from './text/text_editor'; import { ToolManager } from './tools'; @@ -45,6 +48,10 @@ export class Editor { appVersion = 'suika-editor_0.0.1'; paperId: string; + stageManager: StageManager; + selection: Selection; + grid: Grid; + sceneGraph: SceneGraph; controlHandleManager: ControlHandleManager; groupManager: GroupManager; @@ -74,11 +81,15 @@ export class Editor { autoSaveGraphs: AutoSaveGraphs; perfMonitor: PerfMonitor; + async init() { + // ... + } + constructor(options: IEditorOptions) { this.containerElement = options.containerElement; this.canvasElement = document.createElement('canvas'); this.containerElement.appendChild(this.canvasElement); - this.ctx = this.canvasElement.getContext('2d')!; + this.ctx = document.createElement('canvas').getContext('2d')!; this.setting = new Setting(); if (options.offsetX) { @@ -102,15 +113,11 @@ export class Editor { this.imgManager = new ImgManager(); this.selectedElements = new SelectedElements(this); - this.selectedBox = new SelectedBox(this); this.ruler = new Ruler(this); this.refLine = new RefLine(this); this.textEditor = new TextEditor(this); this.pathEditor = new PathEditor(this); - this.controlHandleManager = new ControlHandleManager(this); - this.controlHandleManager.bindEvents(); - this.hostEventManager = new HostEventManager(this); this.hostEventManager.bindHotkeys(); @@ -126,6 +133,8 @@ export class Editor { this.render(); }); + this.stageManager = new StageManager(this); + const data = this.autoSaveGraphs.load(); if (data) { this.loadData(data); @@ -143,6 +152,24 @@ export class Editor { this.zoomManager.zoomToFit(1); + this.stageManager.init(this.canvasElement); + + // grid + this.grid = new Grid(this); + this.stageManager.addView(this.grid.getGraphics()); + + // selectedBox + this.selectedBox = new SelectedBox(this); + this.stageManager.addView(this.selectedBox.getGraphics()); + + this.controlHandleManager = new ControlHandleManager(this); + this.stageManager.addView(this.controlHandleManager.getGraphics()); + this.controlHandleManager.bindEvents(); + + // selection rect + this.selection = new Selection(this); + this.stageManager.addView(this.selection.getGraphics()); + this.perfMonitor = new PerfMonitor(); if (options.showPerfMonitor) { this.perfMonitor.start(this.containerElement); @@ -167,6 +194,7 @@ export class Editor { } destroy() { this.containerElement.removeChild(this.canvasElement); + this.stageManager.destroy(); this.textEditor.destroy(); this.keybindingManager.destroy(); this.hostEventManager.destroy(); @@ -176,6 +204,7 @@ export class Editor { this.toolManager.destroy(); this.perfMonitor.destroy(); this.controlHandleManager.unbindEvents(); + this.grid.destroy(); } setCursor(cursor: ICursor) { this.cursorManager.setCursor(cursor); diff --git a/packages/core/src/graphs/ellipse.ts b/packages/core/src/graphs/ellipse.ts index 79488287..657a211c 100644 --- a/packages/core/src/graphs/ellipse.ts +++ b/packages/core/src/graphs/ellipse.ts @@ -1,8 +1,7 @@ -import { parseRGBAStr } from '@suika/common'; -import { Matrix } from 'pixi.js'; +import { calcCoverScale } from '@suika/common'; +import { Assets, Container, Graphics, Matrix, Sprite } from 'pixi.js'; import { DOUBLE_PI } from '../constant'; -import { type ImgManager } from '../Img_manager'; import { PaintType } from '../paint'; import { GraphType, type Optional } from '../type'; import { Graph, type GraphAttrs, type IGraphOpts } from './graph'; @@ -38,46 +37,163 @@ export class Ellipse extends Graph { ); } - override draw( - ctx: CanvasRenderingContext2D, - imgManager?: ImgManager, - smooth?: boolean, - ): void { - const attrs = this.attrs; - const cx = attrs.width / 2; - const cy = attrs.height / 2; + override draw(): // ctx: CanvasRenderingContext2D, + // imgManager?: ImgManager, + // smooth?: boolean, + void { + // const attrs = this.attrs; + // const cx = attrs.width / 2; + // const cy = attrs.height / 2; - ctx.transform(...attrs.transform); + // ctx.transform(...attrs.transform); - ctx.beginPath(); - ctx.ellipse(cx, cy, attrs.width / 2, attrs.height / 2, 0, 0, DOUBLE_PI); - for (const paint of attrs.fill ?? []) { - if (paint.type === PaintType.Solid) { - ctx.fillStyle = parseRGBAStr(paint.attrs); - ctx.fill(); - } else if (paint.type === PaintType.Image) { - if (imgManager) { - ctx.clip(); - this.fillImage(ctx, paint, imgManager, smooth); - } else { - console.warn('ImgManager is not provided'); - } - } + // ctx.beginPath(); + // ctx.ellipse(cx, cy, attrs.width / 2, attrs.height / 2, 0, 0, DOUBLE_PI); + // for (const paint of attrs.fill ?? []) { + // if (paint.type === PaintType.Solid) { + // ctx.fillStyle = parseRGBAStr(paint.attrs); + // ctx.fill(); + // } else if (paint.type === PaintType.Image) { + // if (imgManager) { + // ctx.clip(); + // this.fillImage(ctx, paint, imgManager, smooth); + // } else { + // console.warn('ImgManager is not provided'); + // } + // } + // } + + // if (attrs.strokeWidth) { + // ctx.lineWidth = attrs.strokeWidth; + // for (const paint of attrs.stroke ?? []) { + // if (paint.type === PaintType.Solid) { + // ctx.strokeStyle = parseRGBAStr(paint.attrs); + // ctx.stroke(); + // } else if (paint.type === PaintType.Image) { + // // TODO: + // } + // } + // } + + // ctx.closePath(); + + this.drawByPixi(); + } + + // override drawByPixi() { + // if (!this.graphics) { + // this.graphics = new Graphics(); + // } + // const graphics = this.graphics as Graphics; + + // graphics.clear(); + + // const attrs = this.attrs; + // graphics.setFromMatrix(new Matrix(...attrs.transform)); + // const halfWidth = attrs.width / 2; + // const halfHeight = attrs.height / 2; + + // for (const paint of this.attrs.fill ?? []) { + // if (paint.type === PaintType.Solid) { + // graphics.ellipse(halfWidth, halfHeight, halfWidth, halfHeight); + // graphics.fill(paint.attrs); + // } + // } + + // const strokeWidth = this.getStrokeWidth(); + // for (const paint of this.attrs.stroke ?? []) { + // if (paint.type === PaintType.Solid) { + // graphics.ellipse(halfWidth, halfHeight, halfWidth, halfHeight); + // graphics.stroke({ width: strokeWidth, color: paint.attrs }); + // } + // } + + // this.graphics = graphics; + // return graphics; + // } + + /** + * TODO: 和 rect 的逻辑重复了,考虑抽一个公共方法 + */ + override drawByPixi() { + if (!this.graphics) { + this.graphics = new Container(); } - if (attrs.strokeWidth) { - ctx.lineWidth = attrs.strokeWidth; - for (const paint of attrs.stroke ?? []) { + const _draw = () => { + const graphics = this.graphics; + if (!graphics) return; + + // reset + graphics.removeChildren(); + graphics.mask = null; + + const attrs = this.attrs; + graphics.visible = attrs.visible ?? true; + graphics.setFromMatrix(new Matrix(...attrs.transform)); + const halfWidth = attrs.width / 2; + const halfHeight = attrs.height / 2; + + const fillContainer = new Container(); + graphics.addChild(fillContainer); + + // mask + if (imgUrlSet.size) { + const mask = new Graphics() + .ellipse(halfWidth, halfHeight, halfWidth, halfHeight) + .fill(); + fillContainer.addChild(mask); + fillContainer.mask = mask; + } + + // fill + for (const paint of this.attrs.fill ?? []) { if (paint.type === PaintType.Solid) { - ctx.strokeStyle = parseRGBAStr(paint.attrs); - ctx.stroke(); + const solidGraphics = new Graphics() + .ellipse(halfWidth, halfHeight, halfWidth, halfHeight) + .fill(paint.attrs); + fillContainer.addChild(solidGraphics); } else if (paint.type === PaintType.Image) { - // TODO: + const sprite = Sprite.from(paint.attrs.src!); + + const img = sprite.texture.source; + const scale = calcCoverScale( + img.width, + img.height, + attrs.width, + attrs.height, + ); + const sx = (img.width * scale) / 2 - attrs.width / 2; + const sy = (img.height * scale) / 2 - attrs.height / 2; + + sprite.x = -sx; + sprite.y = -sy; + sprite.width = img.width * scale; + sprite.height = img.height * scale; + + fillContainer.addChild(sprite); } } - } - ctx.closePath(); + // stroke + const strokeWidth = this.getStrokeWidth(); + for (const paint of this.attrs.stroke ?? []) { + if (paint.type === PaintType.Solid) { + const solidGraphics = new Graphics() + .ellipse(halfWidth, halfHeight, halfWidth, halfHeight) + .stroke({ width: strokeWidth, color: paint.attrs }); + + graphics.addChild(solidGraphics); + } + } + }; + + const imgUrlSet = this.getImgUrlSet(); + if (imgUrlSet.size) { + Assets.load(Array.from(imgUrlSet)).then(_draw); + } else { + _draw(); + } } override drawOutline( diff --git a/packages/core/src/graphs/graph/graph.ts b/packages/core/src/graphs/graph/graph.ts index 0f81f393..44f6090d 100644 --- a/packages/core/src/graphs/graph/graph.ts +++ b/packages/core/src/graphs/graph/graph.ts @@ -24,14 +24,15 @@ import { resizeLine, resizeRect, } from '@suika/geo'; -import { Matrix } from 'pixi.js'; +import { type Container, Matrix } from 'pixi.js'; import { HALF_PI } from '../../constant'; import { type ControlHandle } from '../../control_handle_manager'; import { type ImgManager } from '../../Img_manager'; -import { DEFAULT_IMAGE, type PaintImage } from '../../paint'; +import { DEFAULT_IMAGE, type PaintImage, PaintType } from '../../paint'; import { GraphType, type IObject, type Optional } from '../../type'; import { drawRoundRectPath } from '../../utils'; +import { type RectAttrs } from '../rect'; import { type GraphAttrs, type IGraphOpts } from './graph_attrs'; export class Graph { @@ -83,7 +84,7 @@ export class Graph { ); } updateAttrs( - partialAttrs: Partial & IGraphOpts, + partialAttrs: Partial & IGraphOpts, // eslint-disable-next-line @typescript-eslint/no-unused-vars _options?: { finishRecomputed?: boolean }, ) { @@ -110,6 +111,8 @@ export class Graph { // eslint-disable-next-line @typescript-eslint/no-this-alias, @typescript-eslint/no-explicit-any (this.attrs as any)[key] = partialAttrs[key as keyof typeof partialAttrs]; } + + this.drawByPixi(); } getStrokeWidth() { @@ -350,6 +353,17 @@ export class Graph { this.updateAttrs(rect, { finishRecomputed: true }); } + + protected graphics: Container | null = null; + + getGraphics() { + return this.graphics; + } + + drawByPixi() { + // noop + } + draw( // eslint-disable-next-line @typescript-eslint/no-unused-vars _ctx: CanvasRenderingContext2D, @@ -554,4 +568,21 @@ export class Graph { }, ]; } + + protected getImgUrlSet() { + const imgUrlSet = new Set(); + const paints = this.attrs.fill ?? []; + if (this.attrs.stroke) { + paints.concat(this.attrs.stroke); + } + + for (const paint of paints) { + if (paint.type === PaintType.Image) { + if (paint.attrs.src) { + imgUrlSet.add(paint.attrs.src); + } + } + } + return imgUrlSet; + } } diff --git a/packages/core/src/graphs/line.ts b/packages/core/src/graphs/line.ts index 88c4dfb9..849a5e43 100644 --- a/packages/core/src/graphs/line.ts +++ b/packages/core/src/graphs/line.ts @@ -1,4 +1,5 @@ import { parseRGBAStr } from '@suika/common'; +import { Graphics, Matrix } from 'pixi.js'; import { PaintType } from '../paint'; import { GraphType, type Optional } from '../type'; @@ -64,4 +65,26 @@ export class Line extends Graph { ctx.stroke(); ctx.closePath(); } + + override drawByPixi() { + if (!this.graphics) { + this.graphics = new Graphics(); + } + const graphics = this.graphics as Graphics; + + graphics.clear(); + + const attrs = this.attrs; + graphics.visible = attrs.visible ?? true; + + graphics.setFromMatrix(new Matrix(...attrs.transform)); + + const strokeWidth = this.getStrokeWidth(); + for (const paint of this.attrs.stroke ?? []) { + if (paint.type === PaintType.Solid) { + graphics.moveTo(0, 0).lineTo(attrs.width, 0); + graphics.stroke({ width: strokeWidth, color: paint.attrs }); + } + } + } } diff --git a/packages/core/src/graphs/path/path.ts b/packages/core/src/graphs/path/path.ts index 3fb7119b..3f3c4436 100644 --- a/packages/core/src/graphs/path/path.ts +++ b/packages/core/src/graphs/path/path.ts @@ -1,4 +1,9 @@ -import { cloneDeep, parseHexToRGBA, parseRGBAStr } from '@suika/common'; +import { + calcCoverScale, + cloneDeep, + parseHexToRGBA, + parseRGBAStr, +} from '@suika/common'; import { addPoint, type IMatrixArr, @@ -9,7 +14,7 @@ import { resizeRect, } from '@suika/geo'; import { Bezier } from 'bezier-js'; -import { Matrix } from 'pixi.js'; +import { Assets, Container, Graphics, Matrix, Sprite } from 'pixi.js'; import { type ImgManager } from '../../Img_manager'; import { type IPaint, PaintType } from '../../paint'; @@ -181,11 +186,122 @@ export class Path extends Graph { return pathData; } + override drawByPixi() { + if (!this.graphics) { + this.graphics = new Container(); + } + + const imgUrlSet = this.getImgUrlSet(); + + const _draw = () => { + const graphics = this.graphics; + if (!graphics) return; + + // reset + graphics.removeChildren(); + graphics.mask = null; + + const attrs = this.attrs; + graphics.visible = attrs.visible ?? true; + graphics.setFromMatrix(new Matrix(...attrs.transform)); + + const fillContainer = new Container(); + graphics.addChild(fillContainer); + + const pathGraphics = new Graphics(); + for (const pathItem of attrs.pathData) { + const first = pathItem.segs[0]; + if (!first) continue; + pathGraphics.moveTo(first.point.x, first.point.y); + + const segs = pathItem.segs; + for (let i = 1; i <= segs.length; i++) { + if (i === segs.length && !pathItem.closed) { + continue; + } + const currSeg = segs[i % segs.length]; + const prevSeg = segs[i - 1]; + const pointX = currSeg.point.x; + const pointY = currSeg.point.y; + const handle1 = Path.getHandleOut(prevSeg); + const handle2 = Path.getHandleIn(currSeg); + if (!handle1 && !handle2) { + pathGraphics.lineTo(pointX, pointY); + } else { + pathGraphics.bezierCurveTo( + handle1.x, + handle1.y, + handle2.x, + handle2.y, + pointX, + pointY, + ); + } + } + if (pathItem.closed) { + pathGraphics.closePath(); + } + } + + // mask + if (imgUrlSet.size) { + const mask = pathGraphics.clone(true).fill(); + fillContainer.addChild(mask); + fillContainer.mask = mask; + } + + // fill + for (const paint of this.attrs.fill ?? []) { + if (paint.type === PaintType.Solid) { + const solidGraphics = pathGraphics.clone(true).fill(paint.attrs); + fillContainer.addChild(solidGraphics); + } else if (paint.type === PaintType.Image) { + const sprite = Sprite.from(paint.attrs.src!); + + const img = sprite.texture.source; + const scale = calcCoverScale( + img.width, + img.height, + attrs.width, + attrs.height, + ); + const sx = (img.width * scale) / 2 - attrs.width / 2; + const sy = (img.height * scale) / 2 - attrs.height / 2; + + sprite.x = -sx; + sprite.y = -sy; + sprite.width = img.width * scale; + sprite.height = img.height * scale; + + fillContainer.addChild(sprite); + } + } + + // stroke + const strokeWidth = this.getStrokeWidth(); + for (const paint of this.attrs.stroke ?? []) { + if (paint.type === PaintType.Solid) { + const solidGraphics = pathGraphics + .clone(true) + .stroke({ width: strokeWidth, color: paint.attrs }); + graphics.addChild(solidGraphics); + } + } + }; + + if (imgUrlSet.size) { + Assets.load(Array.from(imgUrlSet)).then(_draw); + } else { + _draw(); + } + } + override draw( ctx: CanvasRenderingContext2D, imgManager?: ImgManager | undefined, smooth?: boolean | undefined, ) { + this.drawByPixi(); this._realDraw(ctx, imgManager, smooth); } diff --git a/packages/core/src/graphs/rect.ts b/packages/core/src/graphs/rect.ts index 65bd0f16..13e1097c 100644 --- a/packages/core/src/graphs/rect.ts +++ b/packages/core/src/graphs/rect.ts @@ -1,6 +1,6 @@ -import { parseHexToRGBA, parseRGBAStr } from '@suika/common'; +import { calcCoverScale, parseHexToRGBA, parseRGBAStr } from '@suika/common'; import { boxToRect, type IPoint, isPointInRoundRect } from '@suika/geo'; -import { Matrix } from 'pixi.js'; +import { Assets, Container, Graphics, Matrix, Sprite } from 'pixi.js'; import { ControlHandle } from '../control_handle_manager'; import { type ImgManager } from '../Img_manager'; @@ -105,12 +105,94 @@ export class Rect extends Graph { ctx.closePath(); } + override drawByPixi() { + if (!this.graphics) { + this.graphics = new Container(); + } + + const imgUrlSet = this.getImgUrlSet(); + + const _draw = () => { + const graphics = this.graphics; + if (!graphics) return; + + // reset + graphics.removeChildren(); + graphics.mask = null; + + const attrs = this.attrs; + graphics.visible = attrs.visible ?? true; + graphics.setFromMatrix(new Matrix(...attrs.transform)); + const cornerRadius = this.attrs.cornerRadius ?? 0; + + const fillContainer = new Container(); + graphics.addChild(fillContainer); + + // mask + if (imgUrlSet.size) { + const mask = new Graphics() + .roundRect(0, 0, attrs.width, attrs.height, cornerRadius) + .fill(); + fillContainer.addChild(mask); + fillContainer.mask = mask; + } + + // fill + for (const paint of this.attrs.fill ?? []) { + if (paint.type === PaintType.Solid) { + const solidGraphics = new Graphics() + .roundRect(0, 0, attrs.width, attrs.height, cornerRadius) + .fill(paint.attrs); + fillContainer.addChild(solidGraphics); + } else if (paint.type === PaintType.Image) { + const sprite = Sprite.from(paint.attrs.src!); + + const img = sprite.texture.source; + const scale = calcCoverScale( + img.width, + img.height, + attrs.width, + attrs.height, + ); + const sx = (img.width * scale) / 2 - attrs.width / 2; + const sy = (img.height * scale) / 2 - attrs.height / 2; + + sprite.x = -sx; + sprite.y = -sy; + sprite.width = img.width * scale; + sprite.height = img.height * scale; + + fillContainer.addChild(sprite); + } + } + + // stroke + const strokeWidth = this.getStrokeWidth(); + for (const paint of this.attrs.stroke ?? []) { + if (paint.type === PaintType.Solid) { + const solidGraphics = new Graphics() + .roundRect(0, 0, attrs.width, attrs.height, cornerRadius) + .stroke({ width: strokeWidth, color: paint.attrs }); + + graphics.addChild(solidGraphics); + } + } + }; + + if (imgUrlSet.size) { + Assets.load(Array.from(imgUrlSet)).then(_draw); + } else { + _draw(); + } + } + override draw( ctx: CanvasRenderingContext2D, imgManager?: ImgManager, smooth?: boolean, ) { this._realDraw(ctx, imgManager, smooth); + this.drawByPixi(); } override drawOutline( diff --git a/packages/core/src/graphs/text.ts b/packages/core/src/graphs/text.ts index 0b0999ce..3e8c2687 100644 --- a/packages/core/src/graphs/text.ts +++ b/packages/core/src/graphs/text.ts @@ -1,4 +1,5 @@ import { parseRGBAStr } from '@suika/common'; +import { Matrix, Text, TextStyle } from 'pixi.js'; import { PaintType } from '../paint'; import { GraphType, type Optional } from '../type'; @@ -67,5 +68,47 @@ export class TextGraph extends Graph { } ctx.fillText(content, 0, 0); + + this.drawByPixi(); + } + + override drawByPixi() { + if (!this.graphics) { + this.graphics = new Text(); + } + const textGraphics = this.graphics as Text; + + const parent = textGraphics.parent; + const scale = parent ? parent.localTransform.a ?? 1 : 1; + + const attrs = this.attrs; + textGraphics.visible = attrs.visible ?? true; + const { x, y } = this.getPosition(); + textGraphics.setFromMatrix( + new Matrix(...attrs.transform).prepend( + new Matrix() + .translate(-x, -y) + .scale(1 / scale, 1 / scale) + .translate(x, y), + ), + ); + + const style = new TextStyle({ + // fontFamily: 'Arial', + fontSize: attrs.fontSize * scale, + // fill: { fill }, + // stroke: { color: '#4a1850', width: 5, join: 'round' }, + // dropShadow: { + // color: '#000000', + // blur: 4, + // angle: Math.PI / 6, + // distance: 6, + // }, + // wordWrap: true, + // wordWrapWidth: 440, + }); + + textGraphics.text = attrs.content; + textGraphics.style = style; } } diff --git a/packages/core/src/grid.ts b/packages/core/src/grid.ts index ecf6af79..0be1d42f 100644 --- a/packages/core/src/grid.ts +++ b/packages/core/src/grid.ts @@ -1,4 +1,9 @@ -import { getClosestTimesVal, nearestPixelVal } from '@suika/common'; +import { + getClosestTimesVal, + getDevicePixelRatio, + nearestPixelVal, +} from '@suika/common'; +import { Container, Graphics } from 'pixi.js'; import { type Editor } from './editor'; @@ -6,9 +11,30 @@ import { type Editor } from './editor'; * draw grid */ class Grid { - constructor(private editor: Editor) {} - draw() { - const ctx = this.editor.ctx; + private gridGraphics = new Container(); + constructor(private editor: Editor) { + this.bindEvent(); + } + + getGraphics() { + return this.gridGraphics; + } + + draw = () => { + const gridView = this.gridGraphics; + gridView.removeChildren(); + + const zoom = this.editor.zoomManager.getZoom(); + const setting = this.editor.setting; + + if ( + !( + setting.get('enablePixelGrid') && + zoom >= this.editor.setting.get('minPixelGridZoom') + ) + ) { + return; + } const { x: offsetX, @@ -16,8 +42,6 @@ class Grid { width, height, } = this.editor.viewportManager.getViewport(); - const zoom = this.editor.zoomManager.getZoom(); - const setting = this.editor.setting; const stepX = this.editor.setting.get('gridViewX'); const stepY = this.editor.setting.get('gridViewY'); @@ -25,14 +49,17 @@ class Grid { let startXInScene = getClosestTimesVal(offsetX, stepX); const endXInScene = getClosestTimesVal(offsetX + width / zoom, stepX); + const strokeColor = setting.get('pixelGridLineColor'); + const strokeWidth = 1 / getDevicePixelRatio(); + while (startXInScene <= endXInScene) { - ctx.strokeStyle = setting.get('pixelGridLineColor'); const x = nearestPixelVal((startXInScene - offsetX) * zoom); - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, height); - ctx.stroke(); - ctx.closePath(); + const line = new Graphics().moveTo(x, 0).lineTo(x, height).stroke({ + color: strokeColor, + width: strokeWidth, + }); + gridView.addChild(line); + startXInScene += stepX; } @@ -41,15 +68,30 @@ class Grid { const endYInScene = getClosestTimesVal(offsetY + height / zoom, stepY); while (startYInScene <= endYInScene) { - ctx.strokeStyle = setting.get('pixelGridLineColor'); const y = nearestPixelVal((startYInScene - offsetY) * zoom); - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(width, y); - ctx.stroke(); - ctx.closePath(); + const line = new Graphics().moveTo(0, y).lineTo(width, y).stroke({ + color: strokeColor, + width: strokeWidth, + }); + gridView.addChild(line); + startYInScene += stepY; } + }; + + destroy() { + this.unbindEvent(); + } + + private bindEvent() { + this.editor.zoomManager.on('zoomChange', this.draw); + this.editor.viewportManager.on('xOrYChange', this.draw); + this.editor.viewportManager.on('sizeChange', this.draw); + } + private unbindEvent() { + this.editor.zoomManager.off('zoomChange', this.draw); + this.editor.viewportManager.on('xOrYChange', this.draw); + this.editor.viewportManager.on('sizeChange', this.draw); } } diff --git a/packages/core/src/path_editor/path_editor.ts b/packages/core/src/path_editor/path_editor.ts index a8f86861..b556dcbc 100644 --- a/packages/core/src/path_editor/path_editor.ts +++ b/packages/core/src/path_editor/path_editor.ts @@ -45,7 +45,10 @@ export class PathEditor { this.path = path; const editor = this.editor; - editor.sceneGraph.showSelectedGraphsOutline = false; + // editor.sceneGraph.showSelectedGraphsOutline = false; + + editor.selectedBox.visible(false); + editor.sceneGraph.highlightLayersOnHover = false; editor.controlHandleManager.enableTransformControl = false; @@ -79,7 +82,9 @@ export class PathEditor { this.selectedControl.clear(); this.path = null; const editor = this.editor; - editor.sceneGraph.showSelectedGraphsOutline = true; + // editor.sceneGraph.showSelectedGraphsOutline = true; + editor.selectedBox.visible(true); + editor.sceneGraph.highlightLayersOnHover = true; editor.controlHandleManager.enableTransformControl = true; diff --git a/packages/core/src/scene/scene_graph.ts b/packages/core/src/scene/scene_graph.ts index c2ec9437..39f4f8f9 100644 --- a/packages/core/src/scene/scene_graph.ts +++ b/packages/core/src/scene/scene_graph.ts @@ -4,7 +4,7 @@ import { forEach, getDevicePixelRatio, } from '@suika/common'; -import { type IPoint, type IRect, isBoxIntersect, rectToBox } from '@suika/geo'; +import { type IPoint, isBoxIntersect } from '@suika/geo'; import { type Editor } from '../editor'; import { @@ -16,9 +16,7 @@ import { Rect, TextGraph, } from '../graphs'; -import Grid from '../grid'; import { GraphType, type IEditorPaperData, type IObject } from '../type'; -import { rafThrottle } from '../utils'; const graphCtorMap = { [GraphType.Graph]: Graph, @@ -35,27 +33,28 @@ interface Events { export class SceneGraph { children: Graph[] = []; - selection: { - x: number; - y: number; - width: number; - height: number; - } | null = null; + private eventEmitter = new EventEmitter(); - private grid: Grid; showBoxAndHandleWhenSelected = true; showSelectedGraphsOutline = true; highlightLayersOnHover = true; - constructor(private editor: Editor) { - this.grid = new Grid(editor); - } + constructor(private editor: Editor) {} - addItems(element: Graph[], idx?: number) { + addItems(items: Graph[], idx?: number) { if (idx === undefined) { - this.children.push(...element); + this.children.push(...items); + + for (const item of items) { + item.drawByPixi(); + const graphics = item.getGraphics(); + if (graphics) { + this.editor.stageManager.addItems([graphics]); + } + } } else { - this.children.splice(idx, 0, ...element); + this.children.splice(idx, 0, ...items); + // TODO: } } @@ -90,7 +89,17 @@ export class SceneGraph { } } // 全局重渲染 - render = rafThrottle(() => { + + render = () => { + // noop + this.eventEmitter.emit('render'); + }; + + render2 = () => { + // const flag = true; + // if (flag) { + // return; + // } // 获取视口区域 const { viewportManager, @@ -134,15 +143,15 @@ export class SceneGraph { ctx.scale(dpr * zoom, dpr * zoom); ctx.translate(dx, dy); - const imgManager = this.editor.imgManager; - for (let i = 0, len = visibleGraphsInViewport.length; i < len; i++) { - ctx.save(); - const element = visibleGraphsInViewport[i]; - // 抗锯齿 - const smooth = zoom <= 1; - element.draw(ctx, imgManager, smooth); - ctx.restore(); - } + // const imgManager = this.editor.imgManager; + // for (let i = 0, len = visibleGraphsInViewport.length; i < len; i++) { + // ctx.save(); + // const element = visibleGraphsInViewport[i]; + // // 抗锯齿 + // const smooth = zoom <= 1; + // element.draw(ctx, imgManager, smooth); + // ctx.restore(); + // } /********** draw guide line *********/ ctx.save(); @@ -150,12 +159,12 @@ export class SceneGraph { ctx.scale(dpr, dpr); /** draw pixel grid */ - if ( - setting.get('enablePixelGrid') && - zoom >= this.editor.setting.get('minPixelGridZoom') - ) { - this.grid.draw(); - } + // if ( + // setting.get('enablePixelGrid') && + // zoom >= this.editor.setting.get('minPixelGridZoom') + // ) { + // this.grid.draw(); + // } /** draw hover graph outline and its control handle */ if (this.highlightLayersOnHover && setting.get('highlightLayersOnHover')) { @@ -169,59 +178,61 @@ export class SceneGraph { } } - const selectedTransformBox = this.editor.selectedBox.updateBbox(); + // const selectedTransformBox = this.editor.selectedBox.updateBox(); /** draw selected elements outline */ if (this.showSelectedGraphsOutline) { - this.drawGraphsOutline( - this.editor.selectedElements - .getItems() - .filter((item) => item.getVisible()), - setting.get('selectedOutlineStrokeWidth'), - this.editor.setting.get('hoverOutlineStroke'), - ); - this.editor.selectedBox.draw(); + // this.drawGraphsOutline( + // this.editor.selectedElements + // .getItems() + // .filter((item) => item.getVisible()), + // setting.get('selectedOutlineStrokeWidth'), + // this.editor.setting.get('hoverOutlineStroke'), + // ); + // this.editor.selectedBox.updateBoxAndDraw(); } // draw path editor path outline - if (this.editor.pathEditor.isActive()) { - const path = this.editor.pathEditor.getPath(); - if (path) { - this.drawGraphsOutline( - [path], - setting.get('selectedOutlineStrokeWidth'), - this.editor.setting.get('pathLineStroke'), - ); - } - } + // if (this.editor.pathEditor.isActive()) { + // const path = this.editor.pathEditor.getPath(); + // if (path) { + // this.drawGraphsOutline( + // [path], + // setting.get('selectedOutlineStrokeWidth'), + // this.editor.setting.get('pathLineStroke'), + // ); + // } + // } /** draw transform handle */ - if (this.showBoxAndHandleWhenSelected) { - this.editor.controlHandleManager.draw(selectedTransformBox); - } + // if (this.showBoxAndHandleWhenSelected) { + // this.editor.controlHandleManager.draw( + // this.showBoxAndHandleWhenSelected ? selectedTransformBox : null, + // ); + // } /** draw selection */ - if (this.selection) { - ctx.save(); - ctx.strokeStyle = setting.get('selectionStroke'); - ctx.fillStyle = setting.get('selectionFill'); - const { x, y, width, height } = this.selection; - - const { x: xInViewport, y: yInViewport } = - this.editor.sceneCoordsToViewport(x, y); - - const widthInViewport = width * zoom; - const heightInViewport = height * zoom; - - ctx.fillRect(xInViewport, yInViewport, widthInViewport, heightInViewport); - ctx.strokeRect( - xInViewport, - yInViewport, - widthInViewport, - heightInViewport, - ); - ctx.restore(); - } + // if (this.selection) { + // ctx.save(); + // ctx.strokeStyle = setting.get('selectionStroke'); + // ctx.fillStyle = setting.get('selectionFill'); + // const { x, y, width, height } = this.selection; + + // const { x: xInViewport, y: yInViewport } = + // this.editor.sceneCoordsToViewport(x, y); + + // const widthInViewport = width * zoom; + // const heightInViewport = height * zoom; + + // ctx.fillRect(xInViewport, yInViewport, widthInViewport, heightInViewport); + // ctx.strokeRect( + // xInViewport, + // yInViewport, + // widthInViewport, + // heightInViewport, + // ); + // ctx.restore(); + // } this.editor.refLine.drawRefLine(ctx); @@ -233,7 +244,7 @@ export class SceneGraph { ctx.restore(); this.eventEmitter.emit('render'); - }); + }; private drawGraphsOutline( graphs: Graph[], @@ -272,7 +283,7 @@ export class SceneGraph { // const zoom = this.editor.zoomManager.getZoom(); // const ctx = this.editor.ctx; // ctx.save(); - // ctx.strokeStyle = this.editor.setting.get('guideBBoxStroke'); + // ctx.strokeStyle = this.editor.setting.get('selectedBoxStroke'); // if (options?.strokeWidth) { // ctx.lineWidth = options.strokeWidth; // } @@ -307,7 +318,7 @@ export class SceneGraph { // const zoom = this.editor.zoomManager.getZoom(); // const ctx = this.editor.ctx; - // const stroke = this.editor.setting.get('guideBBoxStroke'); + // const stroke = this.editor.setting.get('selectedBoxStroke'); // ctx.save(); // ctx.strokeStyle = stroke; @@ -351,42 +362,43 @@ export class SceneGraph { } return topHitElement; } - setSelection(partialRect: Partial) { - this.selection = Object.assign({}, this.selection, partialRect); - } + // setSelection(partialRect: Partial) { + // this.selection = Object.assign({}, this.selection, partialRect); + // } /** * get elements in selection * * reference: https://mp.weixin.qq.com/s/u0PUOeTryZ11eM2P2Kxwsg */ - getElementsInSelection() { - const selection = this.selection; - if (selection === null) { - console.warn('selection 为 null,请确认在正确的时机调用当前方法'); - return []; - } + // getElementsInSelection() { + // const selection = this.selection; + // if (selection === null) { + // console.warn('selection 为 null,请确认在正确的时机调用当前方法'); + // return []; + // } - const selectionMode = this.editor.setting.get('selectionMode'); - const elements = this.getVisibleItems(); - const containedElements: Graph[] = []; - // TODO: optimize, use r-tree to reduce time complexity - const selectionBox = rectToBox(selection); - for (const el of elements) { - if (el.getLock()) { - continue; - } - let isSelected = false; - if (selectionMode === 'contain') { - isSelected = el.containWithBox(selectionBox); - } else { - isSelected = el.intersectWithBox(selectionBox); - } - if (isSelected) { - containedElements.push(el); - } - } - return containedElements; - } + // const selectionMode = this.editor.setting.get('selectionMode'); + // const elements = this.getVisibleItems(); + // const containedElements: Graph[] = []; + // // TODO: optimize, use r-tree to reduce time complexity + // const selectionBox = rectToBox(selection); + // for (const el of elements) { + // if (el.getLock()) { + // continue; + // } + // let isSelected = false; + // if (selectionMode === 'contain') { + // isSelected = el.containWithBox(selectionBox); + // } else { + // isSelected = el.intersectWithBox(selectionBox); + // } + // if (isSelected) { + // containedElements.push(el); + // } + // } + // return containedElements; + // // return []; + // } /** * get tree data with simple info (for layer panel) @@ -464,7 +476,8 @@ export class SceneGraph { newChildren.push(new Ctor(attrs as any)); } - this.children.push(...newChildren); + // this.children.push(...newChildren); + this.addItems(newChildren); return newChildren; } diff --git a/packages/core/src/selected_box.ts b/packages/core/src/selected_box.ts index 257b1bac..ed8fc9c9 100644 --- a/packages/core/src/selected_box.ts +++ b/packages/core/src/selected_box.ts @@ -5,6 +5,7 @@ import { type ITransformRect, rectToVertices, } from '@suika/geo'; +import { Graphics } from 'pixi.js'; import { type Editor } from './editor'; @@ -13,11 +14,18 @@ interface Events { } export class SelectedBox { + private graphics: Graphics = new Graphics(); private box: ITransformRect | null = null; private eventEmitter = new EventEmitter(); private _hover = false; - constructor(private editor: Editor) {} + constructor(private editor: Editor) { + this.bindEvent(); + } + + getGraphics() { + return this.graphics; + } isHover() { return this._hover; @@ -27,7 +35,7 @@ export class SelectedBox { return this.box ? { ...this.box } : null; } - updateBbox() { + updateBox() { const selectedElements = this.editor.selectedElements; const count = selectedElements.size(); @@ -55,18 +63,34 @@ export class SelectedBox { return this.box; } - draw() { + private bindEvent() { + this.editor.selectedElements.on('itemsChange', this.updateBoxAndDraw); + this.editor.viewportManager.on('xOrYChange', this.updateBoxAndDraw); + } + + private unbindEvent() { + this.editor.selectedElements.off('itemsChange', this.updateBoxAndDraw); + this.editor.viewportManager.off('xOrYChange', this.updateBoxAndDraw); + } + + clear() { + this.graphics.clear(); + } + + visible(val: boolean) { + this.graphics.visible = val; + } + + updateBoxAndDraw = () => { + this.updateBox(); + this.graphics.clear(); + + // 绘制选中框 const bbox = this.box; if (!bbox) { return; } - const ctx = this.editor.ctx; - const stroke = this.editor.setting.get('guideBBoxStroke'); - - ctx.save(); - ctx.strokeStyle = stroke; - const polygon = rectToVertices( { x: 0, @@ -76,17 +100,16 @@ export class SelectedBox { }, bbox.transform, ).map((pt) => this.editor.sceneCoordsToViewport(pt.x, pt.y)); - - ctx.beginPath(); - ctx.moveTo(polygon[0].x, polygon[0].y); + this.graphics.moveTo(polygon[0].x, polygon[0].y); for (let i = 1; i < polygon.length; i++) { - ctx.lineTo(polygon[i].x, polygon[i].y); + this.graphics.lineTo(polygon[i].x, polygon[i].y); } - ctx.closePath(); - ctx.stroke(); - - ctx.restore(); - } + this.graphics.closePath(); + this.graphics.stroke({ + color: this.editor.setting.get('selectedBoxStroke'), + width: this.editor.setting.get('selectedBoxStrokeWidth'), + }); + }; /** check if the point is in the selected box */ hitTest(point: IPoint) { @@ -121,4 +144,8 @@ export class SelectedBox { off(eventName: K, handler: Events[K]) { this.eventEmitter.off(eventName, handler); } + + destroy() { + this.unbindEvent(); + } } diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts new file mode 100644 index 00000000..183c5bfe --- /dev/null +++ b/packages/core/src/selection.ts @@ -0,0 +1,84 @@ +/** + * 辅助线管理。 + * + * + * 图形高亮轮廓线 outlines + * 选区 selection + */ + +import { type IRect, rectToBox } from '@suika/geo'; +import { Graphics } from 'pixi.js'; + +import { type Editor } from './editor'; +import { type Graph } from './graphs'; + +/** + * selection box + */ +export class Selection { + private selectionRect: IRect | null = null; + private selectionGraphics: Graphics; + + getGraphics() { + return this.selectionGraphics; + } + + constructor(private editor: Editor) { + this.selectionGraphics = new Graphics(); + } + + setRect(rect: IRect) { + this.selectionRect = rect; + this.selectionGraphics.clear(); + + const fillColor = this.editor.setting.get('selectionFill'); + const strokeColor = this.editor.setting.get('selectionStroke'); + const zoom = this.editor.zoomManager.getZoom(); + const { x, y, width, height } = rect; + + const { x: xInViewport, y: yInViewport } = + this.editor.sceneCoordsToViewport(x, y); + + const widthInViewport = width * zoom; + const heightInViewport = height * zoom; + + this.selectionGraphics + .rect(xInViewport, yInViewport, widthInViewport, heightInViewport) + .fill(fillColor) + .stroke(strokeColor); + } + + clear() { + this.selectionGraphics.clear(); + } + + getElementsInSelection() { + const selection = this.selectionRect; + if (selection === null) { + console.warn('selection 为 null,请确认在正确的时机调用当前方法'); + return []; + } + + const selectionMode = this.editor.setting.get('selectionMode'); + const elements = this.editor.sceneGraph.getVisibleItems(); + const containedElements: Graph[] = []; + // TODO: optimize, use r-tree to reduce time complexity + const selectionBox = rectToBox(selection); + for (const el of elements) { + if (el.getLock()) { + continue; + } + let isSelected = false; + if (selectionMode === 'contain') { + isSelected = el.containWithBox(selectionBox); + } else { + isSelected = el.intersectWithBox(selectionBox); + } + if (isSelected) { + containedElements.push(el); + } + } + return containedElements; + // return []; + } +} diff --git a/packages/core/src/service/mutate_graphs_and_record.ts b/packages/core/src/service/mutate_graphs_and_record.ts index 26047a2a..b3464d92 100644 --- a/packages/core/src/service/mutate_graphs_and_record.ts +++ b/packages/core/src/service/mutate_graphs_and_record.ts @@ -129,7 +129,9 @@ export const MutateGraphsAndRecord = { cornerRadius: el.attrs.cornerRadius || 0, })); rectGraphics.forEach((el) => { - el.attrs.cornerRadius = cornerRadius; + el.updateAttrs({ + cornerRadius, + }); }); editor.commandManager.pushCommand( new SetGraphsAttrsCmd( @@ -155,7 +157,9 @@ export const MutateGraphsAndRecord = { const newVisible = graphs.some((item) => !item.getVisible()); const prevAttrs = graphs.map((el) => ({ visible: el.attrs.visible })); graphs.forEach((el) => { - el.attrs.visible = newVisible; + el.updateAttrs({ + visible: newVisible, + }); }); editor.commandManager.pushCommand( new SetGraphsAttrsCmd( diff --git a/packages/core/src/setting.ts b/packages/core/src/setting.ts index aa6306f0..5f228d98 100644 --- a/packages/core/src/setting.ts +++ b/packages/core/src/setting.ts @@ -28,7 +28,9 @@ export class Setting { attrs: { r: 0, g: 0, b: 0, a: 0.2 }, } as IPaint, - guideBBoxStroke: '#1592fe', + selectedBoxStroke: '#1592fe', + selectedBoxStrokeWidth: 1.2, + selectionStroke: '#0f8eff', selectionFill: '#0f8eff33', selectionMode: 'intersect' as 'intersect' | 'contain', @@ -85,7 +87,7 @@ export class Setting { enablePixelGrid: true, snapToGrid: true, // 是否吸附到网格 minPixelGridZoom: 8, // draw pixel grid When zoom reach this value - pixelGridLineColor: '#cccccc55', // pixel grid line color + pixelGridLineColor: '#cccccc55', // pixel grid line color // TODO: FIXME: 换成 f2f2f2,然后使用 Plus darker 滤镜 gridViewX: 1, gridViewY: 1, diff --git a/packages/core/src/stage_manger.ts b/packages/core/src/stage_manger.ts new file mode 100644 index 00000000..4ffb3920 --- /dev/null +++ b/packages/core/src/stage_manger.ts @@ -0,0 +1,105 @@ +import { getDevicePixelRatio } from '@suika/common'; +import { Application, Container, GraphicsContextSystem, Matrix } from 'pixi.js'; + +import { type Editor } from './editor'; + +export class StageManager { + private app: Application; + private stage: Container; + private scene: Container; + + constructor(private editor: Editor) { + GraphicsContextSystem.defaultOptions.bezierSmoothness = 0.99; + + this.app = new Application(); + this.stage = this.app.stage; + // 图形绘制的位置 + this.scene = new Container(); + this.stage.addChild(this.scene); + } + + getScene() { + return this.scene; + } + + async init(canvas: HTMLCanvasElement) { + const { width, height } = this.editor.viewportManager.getViewport(); + await this.app.init({ + canvas, + // preference: 'webgpu', + width, + height, + antialias: true, + resolution: getDevicePixelRatio(), + background: this.editor.setting.get('canvasBgColor'), + }); + + this.setSceneTfFromViewportAndZoom(); + this.bindEvent(); + } + + private setSceneTfFromViewportAndZoom = () => { + const { x, y } = this.editor.viewportManager.getViewport(); + const zoom = this.editor.zoomManager.getZoom(); + const matrix = new Matrix().translate(-x, -y).scale(zoom, zoom); + this.scene.setFromMatrix(matrix); + }; + + private onViewportSizeChange = (width: number, height: number) => { + this.app.renderer.resize(width, height); + }; + + private bindEvent() { + this.editor.viewportManager.on( + 'xOrYChange', + this.setSceneTfFromViewportAndZoom, + ); + this.editor.zoomManager.on( + 'zoomChange', + this.setSceneTfFromViewportAndZoom, + ); + this.editor.viewportManager.on('sizeChange', this.onViewportSizeChange); + this.bindRenderTickEvent(); + } + + private unbindEvent() { + this.editor.viewportManager.off( + 'xOrYChange', + this.setSceneTfFromViewportAndZoom, + ); + this.editor.zoomManager.off( + 'zoomChange', + this.setSceneTfFromViewportAndZoom, + ); + this.editor.viewportManager.off('sizeChange', this.onViewportSizeChange); + } + + addItems(graphs: Container[]) { + this.scene.addChild(...graphs); + } + + addView(container: Container) { + this.stage.addChild(container); + } + + bindRenderTickEvent() { + this.app.ticker.add( + () => { + if (this.editor.sceneGraph.showBoxAndHandleWhenSelected) { + this.editor.selectedBox.updateBoxAndDraw(); + const box = this.editor.selectedBox.getBox(); + this.editor.controlHandleManager.draw(box); + } else { + this.editor.selectedBox.clear(); + this.editor.controlHandleManager.clear(); + } + }, + // null, + // UPDATE_PRIORITY.HIGH, + ); + } + + public destroy() { + this.unbindEvent(); + } +} diff --git a/packages/core/src/tools/tool_draw_graph.ts b/packages/core/src/tools/tool_draw_graph.ts index 0ef5ed18..dccdfe25 100644 --- a/packages/core/src/tools/tool_draw_graph.ts +++ b/packages/core/src/tools/tool_draw_graph.ts @@ -219,10 +219,10 @@ export abstract class DrawGraphTool implements ITool { if (this.drawingGraph) { this.updateGraph(rect); + this.editor.selectedBox.updateBoxAndDraw(); } else { const element = this.createGraph(rect)!; sceneGraph.addItems([element]); - this.drawingGraph = element; } this.editor.selectedElements.setItems([this.drawingGraph]); diff --git a/packages/core/src/tools/tool_path_select/tool_path_select_selection.ts b/packages/core/src/tools/tool_path_select/tool_path_select_selection.ts index 83c1af36..05126fcf 100644 --- a/packages/core/src/tools/tool_path_select/tool_path_select_selection.ts +++ b/packages/core/src/tools/tool_path_select/tool_path_select_selection.ts @@ -45,17 +45,24 @@ export class DrawPathSelectionTool implements IBaseTool { this.lastPoint = editor.toolManager.getCurrPoint(); - editor.render(); - editor.sceneGraph.setSelection(this.lastPoint); + this.editor.render(); + // this.editor.sceneGraph.setSelection(this.lastPoint); + this.editor.selection.setRect({ + ...this.lastPoint, + width: 0, + height: 0, + }); } onDrag(e: PointerEvent) { const point = this.editor.getSceneCursorXY(e); - const box = getRectByTwoPoint(this.lastPoint, point); - this.editor.sceneGraph.setSelection(box); + const rect = getRectByTwoPoint(this.lastPoint, point); + this.editor.selection.setRect(rect); const controls = - this.editor.controlHandleManager.getCustomHandlesIntersectedWithRect(box); + this.editor.controlHandleManager.getCustomHandlesIntersectedWithRect( + rect, + ); const info = controls .map((control) => { @@ -101,8 +108,8 @@ export class DrawPathSelectionTool implements IBaseTool { this.editor.pathEditor.selectedControl.clear(); } this.editor.pathEditor.drawControlHandles(); - - this.editor.sceneGraph.selection = null; + // this.editor.sceneGraph.selection = null; + this.editor.selection.clear(); this.editor.render(); } } diff --git a/packages/core/src/tools/tool_select/tool_select_move.ts b/packages/core/src/tools/tool_select/tool_select_move.ts index 93038307..c43d0884 100644 --- a/packages/core/src/tools/tool_select/tool_select_move.ts +++ b/packages/core/src/tools/tool_select/tool_select_move.ts @@ -65,6 +65,8 @@ export class SelectMoveTool implements IBaseTool { this.editor.sceneGraph.showBoxAndHandleWhenSelected = false; this.editor.sceneGraph.showSelectedGraphsOutline = false; + this.editor.selectedBox.clear(); + const { x, y } = this.editor.viewportCoordsToScene( this.dragPoint!.x, this.dragPoint!.y, @@ -157,6 +159,9 @@ export class SelectMoveTool implements IBaseTool { this.editor.sceneGraph.showBoxAndHandleWhenSelected = true; this.editor.sceneGraph.showSelectedGraphsOutline = true; + + this.editor.selectedBox.updateBoxAndDraw(); + this.editor.refLine.clear(); this.editor.render(); } diff --git a/packages/core/src/tools/tool_select/tool_select_resize.ts b/packages/core/src/tools/tool_select/tool_select_resize.ts index 0e41b8b1..774cc988 100644 --- a/packages/core/src/tools/tool_select/tool_select_resize.ts +++ b/packages/core/src/tools/tool_select/tool_select_resize.ts @@ -151,6 +151,7 @@ export class SelectResizeTool implements IBaseTool { this.resizeMultiGraphs(selectItems); } + this.editor.selectedBox.updateBoxAndDraw(); this.editor.render(); } diff --git a/packages/core/src/tools/tool_select/tool_select_rotation.ts b/packages/core/src/tools/tool_select/tool_select_rotation.ts index 5f24891c..03bf7902 100644 --- a/packages/core/src/tools/tool_select/tool_select_rotation.ts +++ b/packages/core/src/tools/tool_select/tool_select_rotation.ts @@ -127,6 +127,8 @@ export class SelectRotationTool implements IBaseTool { } else { throw new Error('no selected elements, please report issue'); } + + this.editor.selectedBox.updateBoxAndDraw(); this.editor.render(); } onEnd() { diff --git a/packages/core/src/tools/tool_select/tool_select_selection.ts b/packages/core/src/tools/tool_select/tool_select_selection.ts index ee5ee6fc..1394a2fd 100644 --- a/packages/core/src/tools/tool_select/tool_select_selection.ts +++ b/packages/core/src/tools/tool_select/tool_select_selection.ts @@ -33,15 +33,20 @@ export class DrawSelection implements IBaseTool { this.lastPoint = this.editor.viewportCoordsToScene(pos.x, pos.y); this.editor.render(); - this.editor.sceneGraph.setSelection(this.lastPoint); + // this.editor.sceneGraph.setSelection(this.lastPoint); + this.editor.selection.setRect({ + ...this.lastPoint, + width: 0, + height: 0, + }); } onDrag(e: PointerEvent) { const point = this.editor.getSceneCursorXY(e); - const box = getRectByTwoPoint(this.lastPoint, point); - this.editor.sceneGraph.setSelection(box); + const rect = getRectByTwoPoint(this.lastPoint, point); + this.editor.selection.setRect(rect); - const graphsInSelection = this.editor.sceneGraph.getElementsInSelection(); + const graphsInSelection = this.editor.selection.getElementsInSelection(); if (this.isShiftPressingWhenStart) { this.editor.selectedElements.setItems(this.startSelectedGraphs); @@ -58,7 +63,8 @@ export class DrawSelection implements IBaseTool { afterEnd() { this.isShiftPressingWhenStart = false; this.startSelectedGraphs = []; - this.editor.sceneGraph.selection = null; + // this.editor.sceneGraph.selection = null; + this.editor.selection.clear(); this.editor.render(); } } diff --git a/packages/core/src/viewport_manager.ts b/packages/core/src/viewport_manager.ts index a1f59598..e3fa5a76 100644 --- a/packages/core/src/viewport_manager.ts +++ b/packages/core/src/viewport_manager.ts @@ -4,44 +4,60 @@ import { type IBox, type IRect } from '@suika/geo'; import { type Editor } from './editor'; interface Events { - xOrYChange(x: number | undefined, y: number): void; + sizeChange(width: number, height: number): void; + xOrYChange(x: number, y: number): void; } export class ViewportManager { - private scrollX = 0; - private scrollY = 0; + private x = 0; + private y = 0; + // private width = 100; + // private height = 100; private eventEmitter = new EventEmitter(); constructor(private editor: Editor) {} getViewport(): IRect { return { - x: this.scrollX, - y: this.scrollY, + x: this.x, + y: this.y, + // width: this.width, width: parseFloat(this.editor.canvasElement.style.width), + // height: this.height, height: parseFloat(this.editor.canvasElement.style.height), }; } setViewport({ x, y, width, height }: Partial) { - const prevX = this.scrollX; - const prevY = this.scrollY; + // const prevX = this.x; + // const prevY = this.y; + const prevViewport = this.getViewport(); const dpr = getDevicePixelRatio(); + + x ??= prevViewport.x; + y ??= prevViewport.y; + width ??= prevViewport.width; + height ??= prevViewport.height; if (x !== undefined) { - this.scrollX = x; + this.x = x; } if (y !== undefined) { - this.scrollY = y; + this.y = y; } if (width !== undefined) { + // this.width = width; this.editor.canvasElement.width = width * dpr; this.editor.canvasElement.style.width = width + 'px'; } if (height !== undefined) { + // this.height = height; this.editor.canvasElement.height = height * dpr; this.editor.canvasElement.style.height = height + 'px'; } - if (prevX !== x || prevY !== y) { - this.eventEmitter.emit('xOrYChange', x as number, y as number); + if (prevViewport.width !== width || prevViewport.height !== height) { + this.eventEmitter.emit('sizeChange', width, height); + } + if (prevViewport.x !== x || prevViewport.y !== y) { + this.eventEmitter.emit('xOrYChange', x, y); } } getCenter() { @@ -53,20 +69,10 @@ export class ViewportManager { }; } translate(dx: number, dy: number) { - this.scrollX += dx; - this.scrollY += dy; - this.eventEmitter.emit('xOrYChange', this.scrollX, this.scrollY); + this.x += dx; + this.y += dy; + this.eventEmitter.emit('xOrYChange', this.x, this.y); } - // getBbox(): IRect { - // const { x, y, width, height } = this.getViewport(); - // const zoom = this.editor.zoomManager.getZoom(); - // return { - // x: x, - // y: y, - // width: width / zoom, - // height: height / zoom, - // }; - // } getBbox(): IBox { const { x, y, width, height } = this.getViewport(); const zoom = this.editor.zoomManager.getZoom(); @@ -77,10 +83,11 @@ export class ViewportManager { maxY: y + height / zoom, }; } - on(eventName: 'xOrYChange', handler: (x: number, y: number) => void) { + on(eventName: K, handler: Events[K]) { this.eventEmitter.on(eventName, handler); } - off(eventName: 'xOrYChange', handler: (x: number, y: number) => void) { + + off(eventName: K, handler: Events[K]) { this.eventEmitter.off(eventName, handler); } } diff --git a/packages/geo/package.json b/packages/geo/package.json index c7eb36a5..ea7a6d2c 100644 --- a/packages/geo/package.json +++ b/packages/geo/package.json @@ -12,7 +12,7 @@ "build": "tsc && vite build" }, "dependencies": { - "pixi.js": "^8.0.2" + "pixi.js": "^8.1.0" }, "devDependencies": { "vite": "^4.2.0" diff --git a/packages/suika/src/components/Cards/FillCard/FillCard.tsx b/packages/suika/src/components/Cards/FillCard/FillCard.tsx index 2fa614c6..515cb3cb 100644 --- a/packages/suika/src/components/Cards/FillCard/FillCard.tsx +++ b/packages/suika/src/components/Cards/FillCard/FillCard.tsx @@ -27,7 +27,9 @@ export const FillCard: FC = () => { const selectItems = editor.selectedElements.getItems(); selectItems.forEach((item) => { - item.attrs.fill = cloneDeep(newFills); + item.updateAttrs({ + fill: cloneDeep(newFills), + }); }); return newFills; @@ -44,7 +46,9 @@ export const FillCard: FC = () => { const selectItems = editor.selectedElements.getItems(); selectItems.forEach((item) => { - item.attrs.fill = cloneDeep(newFills); + item.updateAttrs({ + fill: cloneDeep(newFills), + }); }); pushToHistory('Add Fill', selectItems, newFills); editor?.render(); @@ -58,7 +62,9 @@ export const FillCard: FC = () => { const selectItems = editor.selectedElements.getItems(); selectItems.forEach((item) => { - item.attrs.fill = cloneDeep(newFills); + item.updateAttrs({ + fill: cloneDeep(newFills), + }); }); pushToHistory('Update Fill', selectItems, newFills); editor.render(); diff --git a/packages/suika/src/components/Cards/StrokeCard/StrokeCard.tsx b/packages/suika/src/components/Cards/StrokeCard/StrokeCard.tsx index f4279e89..3d54a168 100644 --- a/packages/suika/src/components/Cards/StrokeCard/StrokeCard.tsx +++ b/packages/suika/src/components/Cards/StrokeCard/StrokeCard.tsx @@ -86,7 +86,9 @@ export const StrokeCard: FC = () => { const selectItems = editor.selectedElements.getItems(); selectItems.forEach((item) => { - item.attrs.stroke = cloneDeep(newStrokes); + item.updateAttrs({ + stroke: cloneDeep(newStrokes), + }); }); return newStrokes; @@ -103,7 +105,9 @@ export const StrokeCard: FC = () => { const selectItems = editor.selectedElements.getItems(); selectItems.forEach((item) => { - item.attrs.stroke = cloneDeep(newStrokes); + item.updateAttrs({ + stroke: cloneDeep(newStrokes), + }); }); pushToHistory('Add Stroke', selectItems, newStrokes, true); editor?.render(); @@ -117,7 +121,9 @@ export const StrokeCard: FC = () => { const selectItems = editor.selectedElements.getItems(); selectItems.forEach((item) => { - item.attrs.stroke = cloneDeep(newStrokes); + item.updateAttrs({ + stroke: cloneDeep(newStrokes), + }); }); pushToHistory('Update Stroke', selectItems, newStrokes); editor.render(); @@ -186,7 +192,9 @@ export const StrokeCard: FC = () => { ); selectedElements.forEach((item) => { - item.attrs.strokeWidth = newStrokeWidth; + item.updateAttrs({ + strokeWidth: newStrokeWidth, + }); }); setStrokeWidth(newStrokeWidth); diff --git a/packages/suika/src/components/Editor.tsx b/packages/suika/src/components/Editor.tsx index fefc9829..c8f6e61b 100644 --- a/packages/suika/src/components/Editor.tsx +++ b/packages/suika/src/components/Editor.tsx @@ -30,6 +30,8 @@ const Editor: FC = () => { }); (window as any).editor = editor; + editor.init(); + const changeViewport = throttle( () => { editor.viewportManager.setViewport({ diff --git a/packages/suika/src/components/ZoomActions/ZoomActions.tsx b/packages/suika/src/components/ZoomActions/ZoomActions.tsx index 31c9f99d..3755ee0f 100644 --- a/packages/suika/src/components/ZoomActions/ZoomActions.tsx +++ b/packages/suika/src/components/ZoomActions/ZoomActions.tsx @@ -138,6 +138,7 @@ export const ZoomActions: FC = () => { if (editor) { const enablePixelGrid = editor.setting.get('enablePixelGrid'); editor.setting.set('enablePixelGrid', !enablePixelGrid); + editor.grid.draw(); editor.render(); setPopoverVisible(false); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6d9c19e..4e4044d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: pixi.js: - specifier: ^8.0.2 - version: 8.0.2 + specifier: ^8.1.0 + version: 8.1.0 react: specifier: ^18.2.0 version: 18.2.0 @@ -229,8 +229,8 @@ importers: packages/geo: dependencies: pixi.js: - specifier: ^8.0.2 - version: 8.0.2 + specifier: ^8.1.0 + version: 8.1.0 devDependencies: vite: specifier: ^4.2.0 @@ -9189,7 +9189,7 @@ packages: object.values: 1.1.6 prop-types: 15.8.1 resolve: 2.0.0-next.4 - semver: 6.3.1 + semver: 6.3.0 string.prototype.matchall: 4.0.8 dev: false @@ -12645,8 +12645,8 @@ packages: resolution: {integrity: sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==} engines: {node: '>= 6'} - /pixi.js@8.0.2: - resolution: {integrity: sha512-E4oHF+cIkHB1BUHka8jKRih3ywDAKt/uRaTNIm4+7rEWMX5LhZZVryp8bcx516ZyQ8VrpBOs5ohrEViqYhCDAw==} + /pixi.js@8.1.0: + resolution: {integrity: sha512-qclFipWxKavNZoOE0QjGgEklbxjc1mpHf46adsxYLz7O7RnV44PPkq1J5Ssa6y1JxtYUX0fwbphoE/gz276glA==} dependencies: '@pixi/colord': 2.9.6 '@types/css-font-loading-module': 0.0.12 @@ -14488,7 +14488,6 @@ packages: /semver@6.3.0: resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} hasBin: true - dev: true /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}