From b0c8b4908a68cfb4b7b3ea2feb7d49ff58be2afa Mon Sep 17 00:00:00 2001 From: xigua Date: Sun, 18 Feb 2024 00:28:10 +0800 Subject: [PATCH] feat: draw path tool --- packages/core/src/clipboard.ts | 4 +- .../commands/{add_shape.ts => add_graphs.ts} | 5 +- packages/core/src/commands/command_manager.ts | 95 ++++-- packages/core/src/commands/group.ts | 2 +- packages/core/src/commands/index.ts | 6 +- packages/core/src/commands/move_elements.ts | 30 -- packages/core/src/commands/move_graphs.ts | 25 ++ .../{remove_element.ts => remove_graphs.ts} | 2 +- .../core/src/commands/set_elements_attrs.ts | 14 +- .../control_handle_manager/control_handle.ts | 12 +- .../control_handle_manager.ts | 114 ++++--- .../core/src/control_handle_manager/util.ts | 14 +- .../core/src/cursor_manager/cursor_manager.ts | 7 +- packages/core/src/editor.ts | 16 +- packages/core/src/graphs/graph.ts | 147 ++++----- packages/core/src/graphs/index.ts | 1 + packages/core/src/graphs/path.ts | 176 +++++++++++ .../host_event_manager/command_key_binding.ts | 13 +- .../move_graphs_key_binding.ts | 4 +- packages/core/src/key_binding_manager.ts | 2 +- packages/core/src/path_editor/path_editor.ts | 203 ++++++++++++ packages/core/src/scene/scene_graph.ts | 37 ++- packages/core/src/selected_elements.ts | 22 +- .../src/service/mutate_graphs_and_record.ts | 20 +- packages/core/src/text/text_editor.ts | 4 +- packages/core/src/tools/index.ts | 1 + packages/core/src/tools/tool_drag_canvas.ts | 13 +- packages/core/src/tools/tool_draw_graph.ts | 23 +- packages/core/src/tools/tool_draw_line.ts | 12 +- packages/core/src/tools/tool_draw_path.ts | 295 ++++++++++++++++++ packages/core/src/tools/tool_draw_rect.ts | 10 +- packages/core/src/tools/tool_draw_text.ts | 10 +- packages/core/src/tools/tool_manager.ts | 152 +++++---- .../core/src/tools/tool_select/tool_select.ts | 26 +- .../src/tools/tool_select/tool_select_move.ts | 39 ++- .../tools/tool_select/tool_select_resize.ts | 6 +- .../tools/tool_select/tool_select_rotation.ts | 9 +- packages/core/src/tools/type.ts | 18 +- packages/core/src/type.ts | 1 + packages/core/tsconfig.json | 3 +- packages/geo/src/geo/geo_point.ts | 10 + packages/geo/src/geo/geo_rect.ts | 36 ++- packages/icons/src/icons/index.ts | 1 + packages/icons/src/icons/pen-outlined.tsx | 31 ++ .../components/Cards/FillCard/FillCard.tsx | 4 +- .../Cards/StrokeCard/StrokeCard.tsx | 6 +- .../Header/components/Toolbar/Toolbar.tsx | 9 +- .../Toolbar/components/ToolBtn/ToolBtn.tsx | 8 +- packages/suika/src/locale/en.json | 1 + packages/suika/src/locale/zh.json | 1 + scripts/dev.js | 5 +- 51 files changed, 1324 insertions(+), 381 deletions(-) rename packages/core/src/commands/{add_shape.ts => add_graphs.ts} (85%) delete mode 100644 packages/core/src/commands/move_elements.ts create mode 100644 packages/core/src/commands/move_graphs.ts rename packages/core/src/commands/{remove_element.ts => remove_graphs.ts} (97%) create mode 100644 packages/core/src/graphs/path.ts create mode 100644 packages/core/src/path_editor/path_editor.ts create mode 100644 packages/core/src/tools/index.ts create mode 100644 packages/core/src/tools/tool_draw_path.ts create mode 100644 packages/icons/src/icons/pen-outlined.tsx diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index d0239fde..7c8aca92 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -1,6 +1,6 @@ import { arrMap, noop, omit } from '@suika/common'; -import { AddShapeCommand } from './commands/add_shape'; +import { AddGraphCmd } from './commands/add_graphs'; import { Editor } from './editor'; import { Graph } from './graphs'; import { IEditorPaperData } from './type'; @@ -120,7 +120,7 @@ export class ClipboardManager { // TODO: duplicated objectName should be renamed editor.commandManager.pushCommand( - new AddShapeCommand('pasted graphs', editor, pastedGraphs), + new AddGraphCmd('pasted graphs', editor, pastedGraphs), ); editor.selectedElements.setItems(pastedGraphs); diff --git a/packages/core/src/commands/add_shape.ts b/packages/core/src/commands/add_graphs.ts similarity index 85% rename from packages/core/src/commands/add_shape.ts rename to packages/core/src/commands/add_graphs.ts index 43986af8..cb572611 100644 --- a/packages/core/src/commands/add_shape.ts +++ b/packages/core/src/commands/add_graphs.ts @@ -2,10 +2,7 @@ import { Editor } from '../editor'; import { Graph } from '../graphs'; import { ICommand } from './type'; -/** - * add elements - */ -export class AddShapeCommand implements ICommand { +export class AddGraphCmd implements ICommand { constructor( public desc: string, private editor: Editor, diff --git a/packages/core/src/commands/command_manager.ts b/packages/core/src/commands/command_manager.ts index 46cec796..47ffcaee 100644 --- a/packages/core/src/commands/command_manager.ts +++ b/packages/core/src/commands/command_manager.ts @@ -13,16 +13,25 @@ interface Events { beforeExecCmd(): void; } +interface ICommandItem { + command: ICommand; + /** + * consider the continue commands marked "isBatched" as one macro command + */ + isBatched?: boolean; +} + /** * Command Manager * * reference: https://mp.weixin.qq.com/s/JBhXeFPTw8O34vOtk05cQg */ export class CommandManager { - redoStack: ICommand[] = []; - undoStack: ICommand[] = []; + redoStack: ICommandItem[] = []; + undoStack: ICommandItem[] = []; private isEnableRedoUndo = true; private emitter = new EventEmitter(); + private isBatching = false; constructor(private editor: Editor) {} @@ -31,14 +40,33 @@ export class CommandManager { return; } if (this.redoStack.length > 0) { - const command = this.redoStack.pop()!; - console.log( - `%c Redo %c ${command.desc}`, - 'background: #f04; color: #ee0', - '', - ); - this.undoStack.push(command); - command.redo(); + const topCmdItem = this.redoStack.pop()!; + const isBatched = topCmdItem.isBatched; + const cmdItems: ICommandItem[] = [topCmdItem]; + + if (isBatched) { + // if the command is batched, redo all the commands marked "isBatched" + while (this.redoStack.length > 0 && this.redoStack.at(-1)!.isBatched) { + const currCmdItem = this.redoStack.pop()!; + cmdItems.push(currCmdItem); + } + console.log('------- [redo] batched start -----'); + } + + for (const cmdItem of cmdItems) { + const command = cmdItem.command; + console.log( + `%c Redo %c ${command.desc}`, + 'background: #f04; color: #ee0', + '', + ); + this.undoStack.push(cmdItem); + command.redo(); + } + + if (isBatched) { + console.log('------- [redo] batched end -----'); + } this.editor.sceneGraph.render(); this.emitStatusChange(); @@ -49,14 +77,33 @@ export class CommandManager { return; } if (this.undoStack.length > 0) { - const command = this.undoStack.pop()!; - console.log( - `%c Undo %c ${command.desc}`, - 'background: #40f; color: #eee', - '', - ); - this.redoStack.push(command); - command.undo(); + const topCmdItem = this.undoStack.pop()!; + const isBatched = topCmdItem.isBatched; + const cmdItems: ICommandItem[] = [topCmdItem]; + + if (isBatched) { + // if the command is batched, undo all the commands marked "isBatched" + while (this.undoStack.length > 0 && this.undoStack.at(-1)!.isBatched) { + const currCmdItem = this.undoStack.pop()!; + cmdItems.push(currCmdItem); + } + console.log('------- [undo] batched start -----'); + } + + for (const cmdItem of cmdItems) { + const command = cmdItem.command; + console.log( + `%c Undo %c ${command.desc}`, + 'background: #40f; color: #eee', + '', + ); + this.redoStack.push(cmdItem); + command.undo(); + } + + if (isBatched) { + console.log('------- [undo] batched end -----'); + } this.editor.sceneGraph.render(); this.emitStatusChange(); @@ -68,6 +115,12 @@ export class CommandManager { disableRedoUndo() { this.isEnableRedoUndo = false; } + batchCommandStart() { + this.isBatching = true; + } + batchCommandEnd() { + this.isBatching = false; + } pushCommand(command: ICommand) { this.emitter.emit('beforeExecCmd'); console.log( @@ -75,7 +128,11 @@ export class CommandManager { 'background: #222; color: #bada55', '', ); - this.undoStack.push(command); + const commandItem: ICommandItem = { command }; + if (this.isBatching) { + commandItem.isBatched = true; + } + this.undoStack.push(commandItem); this.redoStack = []; this.emitStatusChange(); } diff --git a/packages/core/src/commands/group.ts b/packages/core/src/commands/group.ts index 64f97f9b..11397728 100644 --- a/packages/core/src/commands/group.ts +++ b/packages/core/src/commands/group.ts @@ -3,7 +3,7 @@ import { Graph } from '../graphs'; import { Group } from '../group_manager'; import { ICommand } from './type'; -export class GroupElements implements ICommand { +export class GroupCmd implements ICommand { private groupedElSet = new Set(); /** prevGraphId -> groupInfo */ private prevGroupedElInfoMap = new Map< diff --git a/packages/core/src/commands/index.ts b/packages/core/src/commands/index.ts index 167436ff..ed9ba5fd 100644 --- a/packages/core/src/commands/index.ts +++ b/packages/core/src/commands/index.ts @@ -1,8 +1,8 @@ -export * from './add_shape'; +export * from './add_graphs'; export * from './align'; export * from './command_manager'; export * from './group'; -export * from './move_elements'; -export * from './remove_element'; +export * from './move_graphs'; +export * from './remove_graphs'; export * from './set_elements_attrs'; export * from './type'; diff --git a/packages/core/src/commands/move_elements.ts b/packages/core/src/commands/move_elements.ts deleted file mode 100644 index db6d9909..00000000 --- a/packages/core/src/commands/move_elements.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Graph } from '../graphs'; -import { ICommand } from './type'; - -/** - * move elements - */ -export class MoveElementsCommand implements ICommand { - constructor( - public desc: string, - private elements: Graph[], - private dx: number, - private dy: number, - ) {} - redo() { - const { dx, dy } = this; - for (let i = 0, len = this.elements.length; i < len; i++) { - const element = this.elements[i]; - element.x = element.x + dx; - element.y = element.y + dy; - } - } - undo() { - const { dx, dy } = this; - for (let i = 0, len = this.elements.length; i < len; i++) { - const element = this.elements[i]; - element.x = element.x - dx; - element.y = element.y - dy; - } - } -} diff --git a/packages/core/src/commands/move_graphs.ts b/packages/core/src/commands/move_graphs.ts new file mode 100644 index 00000000..3335fc7e --- /dev/null +++ b/packages/core/src/commands/move_graphs.ts @@ -0,0 +1,25 @@ +import { Graph } from '../graphs'; +import { ICommand } from './type'; + +export class MoveGraphsCommand implements ICommand { + constructor( + public desc: string, + private graphs: Graph[], + private dx: number, + private dy: number, + ) {} + redo() { + const { dx, dy } = this; + for (let i = 0, len = this.graphs.length; i < len; i++) { + const element = this.graphs[i]; + element.updateAttrs({ x: element.x + dx, y: element.y + dy }); + } + } + undo() { + const { dx, dy } = this; + for (let i = 0, len = this.graphs.length; i < len; i++) { + const element = this.graphs[i]; + element.updateAttrs({ x: element.x - dx, y: element.y - dy }); + } + } +} diff --git a/packages/core/src/commands/remove_element.ts b/packages/core/src/commands/remove_graphs.ts similarity index 97% rename from packages/core/src/commands/remove_element.ts rename to packages/core/src/commands/remove_graphs.ts index 6393df77..b080cf2b 100644 --- a/packages/core/src/commands/remove_element.ts +++ b/packages/core/src/commands/remove_graphs.ts @@ -2,7 +2,7 @@ import { Editor } from '../editor'; import { Graph } from '../graphs'; import { ICommand } from './type'; -export class RemoveElement implements ICommand { +export class RemoveGraphsCmd implements ICommand { private removedIndexes: number[] = []; constructor( diff --git a/packages/core/src/commands/set_elements_attrs.ts b/packages/core/src/commands/set_elements_attrs.ts index 23eb9503..4963a7cc 100644 --- a/packages/core/src/commands/set_elements_attrs.ts +++ b/packages/core/src/commands/set_elements_attrs.ts @@ -1,4 +1,4 @@ -import { Graph } from '../graphs'; +import { Graph, ISegment } from '../graphs'; import { ITexture } from '../texture'; import { ICommand } from './type'; @@ -15,12 +15,10 @@ export type ISetElementsAttrsType = Partial<{ objectName: string; visible: boolean; lock: boolean; + pathData: ISegment[][]; }>; -/** - * 创建矩形 - */ -export class SetElementsAttrs implements ICommand { +export class SetGraphsAttrsCmd implements ICommand { static readonly type = 'SetElementsAttrs'; constructor( public desc: string, @@ -38,16 +36,16 @@ export class SetElementsAttrs implements ICommand { const { elements, attrs } = this; for (let i = 0, len = this.elements.length; i < len; i++) { if (Array.isArray(attrs)) { - elements[i].setAttrs(attrs[i]); + elements[i].updateAttrs(attrs[i]); } else { - elements[i].setAttrs(attrs); + elements[i].updateAttrs(attrs); } } } undo() { const { elements, prevAttrs } = this; for (let i = 0, len = this.elements.length; i < len; i++) { - elements[i].setAttrs(prevAttrs[i]); + elements[i].updateAttrs(prevAttrs[i]); } } } diff --git a/packages/core/src/control_handle_manager/control_handle.ts b/packages/core/src/control_handle_manager/control_handle.ts index b7a599ca..50a5036a 100644 --- a/packages/core/src/control_handle_manager/control_handle.ts +++ b/packages/core/src/control_handle_manager/control_handle.ts @@ -7,14 +7,18 @@ type HitTest = ( x: number, y: number, tol: number, - rect: IRectWithRotation, + rect: IRectWithRotation | null, ) => boolean; -type GetCursorFn = (type: string, selectedBox: IRectWithRotation) => ICursor; +type GetCursorFn = ( + type: string, + selectedBox: IRectWithRotation | null, +) => ICursor; export class ControlHandle { cx: number; cy: number; + rotation?: number; type: string; graph: Graph; padding: number; @@ -25,6 +29,7 @@ export class ControlHandle { cx?: number; cy?: number; type: string; + rotation?: number; padding?: number; graph: Graph; hitTest?: HitTest; @@ -32,6 +37,9 @@ export class ControlHandle { }) { this.cx = attrs.cx ?? 0; this.cy = attrs.cy ?? 0; + if (attrs.rotation !== undefined) { + this.rotation = attrs.rotation; + } this.type = attrs.type; this.padding = attrs.padding ?? 0; this.graph = attrs.graph; 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 bac59f5c..00756cd6 100644 --- a/packages/core/src/control_handle_manager/control_handle_manager.ts +++ b/packages/core/src/control_handle_manager/control_handle_manager.ts @@ -8,6 +8,7 @@ import { import { ICursor } from '../cursor_manager'; import { Editor } from '../editor'; +import { GraphType } from '../type'; import { ControlHandle } from './control_handle'; import { ITransformHandleType } from './type'; import { createTransformHandles } from './util'; @@ -31,9 +32,10 @@ const types = [ * Control Point Handle */ export class ControlHandleManager { - private visible = false; - private customHandlesVisible = true; + private transformHandlesVisible = false; private transformHandles: Map; + + private customHandlesVisible = false; private customHandles: ControlHandle[] = []; constructor(private editor: Editor) { @@ -47,22 +49,24 @@ export class ControlHandleManager { } private onHoverItemChange = () => { - const hoverItem = this.editor.selectedElements.getHoverItem(); - const isSingleSelectedGraph = this.editor.selectedElements.size() === 1; - const selectedGraph = isSingleSelectedGraph - ? this.editor.selectedElements.getItems()[0] - : null; - const isSelectedBoxHovered = this.editor.selectedBox.isHover(); - - if ( - isSingleSelectedGraph && - selectedGraph && - (hoverItem === selectedGraph || isSelectedBoxHovered) - ) { - const zoom = this.editor.zoomManager.getZoom(); - this.setCustomHandles(selectedGraph.getControlHandles(zoom, true)); - } else { - this.setCustomHandles([]); + if (!this.editor.pathEditor.getActive()) { + const hoverItem = this.editor.selectedElements.getHoverItem(); + const isSingleSelectedGraph = this.editor.selectedElements.size() === 1; + const selectedGraph = isSingleSelectedGraph + ? this.editor.selectedElements.getItems()[0] + : null; + const isSelectedBoxHovered = this.editor.selectedBox.isHover(); + + if ( + isSingleSelectedGraph && + selectedGraph && + (hoverItem === selectedGraph || isSelectedBoxHovered) + ) { + const zoom = this.editor.zoomManager.getZoom(); + this.setCustomHandles(selectedGraph.getControlHandles(zoom, true)); + } else { + this.setCustomHandles([]); + } } this.editor.sceneGraph.render(); }; @@ -80,12 +84,13 @@ export class ControlHandleManager { this.editor.commandManager.off('change', this.onHoverItemChange); } - inactive() { - this.visible = false; - } + private updateTransformHandles(rect: IRectWithRotation | null) { + if (!rect || this.editor.pathEditor.getActive()) { + this.transformHandlesVisible = false; + return; + } + this.transformHandlesVisible = true; - draw(rect: IRectWithRotation) { - this.visible = true; const zoom = this.editor.zoomManager.getZoom(); const handleSize = this.editor.setting.get('handleSize'); const handleStrokeWidth = this.editor.setting.get('handleStrokeWidth'); @@ -146,21 +151,36 @@ export class ControlHandleManager { w.graph.width = e.graph.width = neswHandleWidth; + } - // draw transform handles - const ctx = this.editor.ctx; - - const handles = [ - ...Array.from(this.transformHandles.values()), - ...(this.customHandlesVisible ? this.customHandles : []), - ]; + draw(rect: IRectWithRotation | null) { + this.updateTransformHandles(rect); + const handles: ControlHandle[] = []; + if (this.transformHandlesVisible) { + handles.push(...Array.from(this.transformHandles.values())); + } + if (this.customHandlesVisible) { + handles.push(...this.customHandles); + } + const ctx = this.editor.ctx; handles.forEach((handle) => { - const { x, y } = this.editor.sceneCoordsToViewport(handle.cx, handle.cy); const graph = handle.graph; - graph.x = x - graph.width / 2; - graph.y = y - graph.height / 2; - graph.rotation = rect.rotation; + if (graph.type === GraphType.Path) { + // TODO: + } else { + const { x, y } = this.editor.sceneCoordsToViewport( + handle.cx, + handle.cy, + ); + graph.updateAttrs({ x: x - graph.width / 2, y: y - graph.height / 2 }); + } + if (rect) { + graph.rotation = rect.rotation; + } + if (handle.rotation !== undefined) { + graph.rotation = handle.rotation; + } if (!graph.getVisible()) { return; @@ -175,7 +195,15 @@ export class ControlHandleManager { handleName: string; cursor: ICursor; } | null { - if (!this.visible) { + const handles: ControlHandle[] = []; + if (this.transformHandlesVisible) { + handles.push(...Array.from(this.transformHandles.values())); + } + if (this.customHandlesVisible) { + handles.push(...this.customHandles); + } + + if (handles.length === 0) { return null; } @@ -184,12 +212,7 @@ export class ControlHandleManager { hitPoint.y, ); - const handles = [ - ...Array.from(this.transformHandles.values()), - ...(this.customHandlesVisible ? this.customHandles : []), - ]; - - const selectedBox = this.editor.selectedBox.getBox()!; + const selectedBox = this.editor.selectedBox.getBox(); for (let i = handles.length - 1; i >= 0; i--) { const handle = handles[i]; @@ -222,20 +245,21 @@ export class ControlHandleManager { setCustomHandles(handles: ControlHandle[]) { this.customHandles = handles; } - + clearCustomHandles() { + this.customHandles = []; + } hasCustomHandles() { return this.customHandles.length > 0; } showCustomHandles() { - if (!this.customHandlesVisible && this.hasCustomHandles()) { + if (!this.customHandlesVisible) { this.customHandlesVisible = true; this.editor.sceneGraph.render(); } } - hideCustomHandles() { - if (this.customHandlesVisible && this.hasCustomHandles()) { + if (this.customHandlesVisible) { this.customHandlesVisible = false; this.editor.sceneGraph.render(); } diff --git a/packages/core/src/control_handle_manager/util.ts b/packages/core/src/control_handle_manager/util.ts index f169bac4..fda9d5da 100644 --- a/packages/core/src/control_handle_manager/util.ts +++ b/packages/core/src/control_handle_manager/util.ts @@ -9,8 +9,11 @@ import { ITransformHandleType } from './type'; const getResizeCursor = ( type: string, - selectedBox: IRectWithRotation, + selectedBox: IRectWithRotation | null, ): ICursor => { + if (!selectedBox) { + return 'default'; + } if (selectedBox.height === 0) { // be considered as a line return 'move'; @@ -44,8 +47,11 @@ const getResizeCursor = ( export const getRotationCursor = ( type: string, - selectedBox: IRectWithRotation, + selectedBox: IRectWithRotation | null, ): ICursor => { + if (!selectedBox) { + return 'default'; + } const rotation = selectedBox.rotation ?? 0; let dDegree = 0; @@ -204,9 +210,9 @@ export const createTransformHandles = (params: { x: number, y: number, tol: number, - rect: { x: number; y: number; width: number; height: number }, + rect: { x: number; y: number; width: number; height: number } | null, ) { - if (rect.width === 0 || rect.height === 0) { + if (!rect || rect.width === 0 || rect.height === 0) { return false; } return this.graph.hitTest(x, y, tol); diff --git a/packages/core/src/cursor_manager/cursor_manager.ts b/packages/core/src/cursor_manager/cursor_manager.ts index 61dec644..86fe515c 100644 --- a/packages/core/src/cursor_manager/cursor_manager.ts +++ b/packages/core/src/cursor_manager/cursor_manager.ts @@ -11,9 +11,14 @@ export interface ICursorRotation { degree: number; } +interface ICursorResize { + type: 'resize'; + degree: number; +} + export type ICursor = | 'default' - | { type: 'resize'; degree: number } + | ICursorResize | ICursorRotation | 'grab' | 'grabbing' diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index bf451613..5d768651 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -14,6 +14,7 @@ import { GroupManager } from './group_manager'; import { HostEventManager } from './host_event_manager'; import { ImgManager } from './Img_manager'; import { KeyBindingManager } from './key_binding_manager'; +import { PathEditor } from './path_editor/path_editor'; import { PerfMonitor } from './perf_monitor'; import { RefLine } from './ref_line'; import Ruler from './ruler'; @@ -23,7 +24,7 @@ import SelectedElements from './selected_elements'; import { Setting } from './setting'; import { AutoSaveGraphs } from './store/auto-save-graphs'; import { TextEditor } from './text/text_editor'; -import { ToolManager } from './tools/tool_manager'; +import { ToolManager } from './tools'; import { IEditorPaperData } from './type'; import { ViewportManager } from './viewport_manager'; import { ZoomManager } from './zoom_manager'; @@ -69,6 +70,7 @@ export class Editor { ruler: Ruler; refLine: RefLine; textEditor: TextEditor; + pathEditor: PathEditor; autoSaveGraphs: AutoSaveGraphs; perfMonitor: PerfMonitor; @@ -105,6 +107,7 @@ export class Editor { 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(); @@ -196,6 +199,14 @@ export class Editor { const { x: scrollX, y: scrollY } = this.viewportManager.getViewport(); return sceneCoordsToViewportUtil(x, y, zoom, scrollX, scrollY); } + viewportSizeToScene(size: number) { + const zoom = this.zoomManager.getZoom(); + return size / zoom; + } + sceneSizeToViewport(size: number) { + const zoom = this.zoomManager.getZoom(); + return size * zoom; + } /** get cursor viewport xy */ getCursorXY(event: { clientX: number; clientY: number }) { return { @@ -214,4 +225,7 @@ export class Editor { element.y += dy; } } + render() { + this.sceneGraph.render(); + } } diff --git a/packages/core/src/graphs/graph.ts b/packages/core/src/graphs/graph.ts index 9426f3bd..64183daf 100644 --- a/packages/core/src/graphs/graph.ts +++ b/packages/core/src/graphs/graph.ts @@ -47,8 +47,8 @@ export class Graph { type = GraphType.Graph; id: string; objectName: string; - protected _x: number; - protected _y: number; + x: number; + y: number; width: number; height: number; // color @@ -60,19 +60,7 @@ export class Graph { cornerRadius?: number; visible?: boolean; lock?: boolean; - - get x() { - return this._x; - } - set x(val: number) { - this._x = val; - } - get y() { - return this._y; - } - set y(val: number) { - this._y = val; - } + private _cacheBbox: Readonly | null = null; constructor(options: GraphAttrs) { this.type = options.type ?? this.type; @@ -85,8 +73,8 @@ export class Graph { this.objectName = objectNameGenerator.gen(options.type ?? this.type); } - this._x = options.x; - this._y = options.y; + this.x = options.x; + this.y = options.y; this.width = options.width; this.height = options.height; @@ -125,8 +113,20 @@ export class Graph { visible: this.visible, }; } - setAttrs(attrs: Partial) { + private shouldUpdateBbox(attrs: Partial) { + // TODO: if x, y, width, height value no change, bbox should not be updated + return ( + attrs.x !== undefined || + attrs.y !== undefined || + attrs.width !== undefined || + attrs.height !== undefined + ); + } + updateAttrs(attrs: Partial) { let key: keyof Partial; + if (this.shouldUpdateBbox(attrs)) { + this._cacheBbox = null; + } for (key in attrs) { // eslint-disable-next-line @typescript-eslint/no-this-alias, @typescript-eslint/no-explicit-any const self: any = this; @@ -145,10 +145,14 @@ export class Graph { } /** - * 计算包围盒(不考虑 strokeWidth) - * 考虑旋转 (旋转后的正交包围盒) + * AABB (axis-aligned bounding box), without considering strokeWidth) + * Consider rotation (orthogonal bounding box after rotation) */ - getBBox(): IBox { + getBBox(): Readonly { + if (this._cacheBbox) { + return this._cacheBbox; + } + const [x, y, x2, y2, cx, cy] = getAbsoluteCoords(this); const rotation = this.rotation; if (!rotation) { @@ -164,47 +168,29 @@ export class Graph { const minY = Math.min(nwY, neY, seY, swY); const maxX = Math.max(nwX, neX, seX, swX); const maxY = Math.max(nwY, neY, seY, swY); - return { + this._cacheBbox = { x: minX, y: minY, width: maxX - minX, height: maxY - minY, }; + return this._cacheBbox; } /** - * AABB (axis-aligned bounding box) + * other getBBox with + * minX, minY, maxX, maxY style */ - getBBox2(): IBox2 { - const [x, y, x2, y2, cx, cy] = getAbsoluteCoords(this); - const rotation = this.rotation; - if (!rotation) { - const box = this.getRect(); - return { - minX: box.x, - minY: box.y, - maxX: box.x + box.width, - maxY: box.y + box.height, - }; - } - - const { x: tlX, y: tlY } = transformRotate(x, y, rotation, cx, cy); // 左上 - const { x: trX, y: trY } = transformRotate(x2, y, rotation, cx, cy); // 右上 - const { x: brX, y: brY } = transformRotate(x2, y2, rotation, cx, cy); // 右下 - const { x: blX, y: blY } = transformRotate(x, y2, rotation, cx, cy); // 右下 - - const minX = Math.min(tlX, trX, brX, blX); - const minY = Math.min(tlY, trY, brY, blY); - const maxX = Math.max(tlX, trX, brX, blX); - const maxY = Math.max(tlY, trY, brY, blY); + getBBox2(): Readonly { + const bbox = this.getBBox(); return { - minX, - minY, - maxX, - maxY, + minX: bbox.x, + minY: bbox.y, + maxX: bbox.x + bbox.width, + maxY: bbox.y + bbox.height, }; } getBboxVerts(): [IPoint, IPoint, IPoint, IPoint] { - const [x, y, x2, y2, cx, cy] = getAbsoluteCoords(this); + const [x, y, x2, y2, cx, cy] = getAbsoluteCoords(this.getRect()); const rotation = this.rotation; if (!rotation) { @@ -225,7 +211,7 @@ export class Graph { } /** - * get bbox before rotation + * get rect before rotation */ getRect() { return { @@ -236,9 +222,10 @@ export class Graph { }; } getCenter() { + const rect = this.getRect(); return { - x: this.x + this.width / 2, - y: this.y + this.height / 2, + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, }; } setRotateXY(rotatedX: number, rotatedY: number) { @@ -250,12 +237,15 @@ export class Graph { cx, cy, ); - this.x = x; - this.y = y; + this.updateAttrs({ x, y }); } hitTest(x: number, y: number, padding = 0) { const strokeWidth = (this.strokeWidth ?? 0) / 2; - return isPointInRect({ x, y }, this, padding + strokeWidth); + return isPointInRect( + { x, y }, + this.getRectWithRotation(), + padding + strokeWidth, + ); } /** @@ -271,7 +261,8 @@ export class Graph { } else { // OBB intersect // use SAT algorithm to check intersect - const { x: cx, y: cy } = this.getCenter(); + const bbox = this.getRectWithRotation(); + const [cx, cy] = getRectCenterPoint(bbox); const r = -this.rotation; const s1 = transformRotate(rect.x, rect.y, r, cx, cy); const s2 = transformRotate( @@ -298,12 +289,7 @@ export class Graph { height: rotatedSelectionHeight, }; - isIntersected = isRectIntersect(rotatedSelection, { - x: this.x, - y: this.y, - width: this.width, - height: this.height, - }); + isIntersected = isRectIntersect(rotatedSelection, bbox); } } @@ -320,11 +306,11 @@ export class Graph { setRotatedX(rotatedX: number) { const { x: prevRotatedX } = getRectRotatedXY(this); - this.x = this.x + rotatedX - prevRotatedX; + this.updateAttrs({ x: this.x + rotatedX - prevRotatedX }); } setRotatedY(rotatedY: number) { const { y: prevRotatedY } = getRectRotatedXY(this); - this.y = this.y + rotatedY - prevRotatedY; + this.updateAttrs({ y: this.y + rotatedY - prevRotatedY }); } updateByControlHandle( @@ -338,7 +324,7 @@ export class Graph { this.height === 0 ? getResizedLine(type, newPos, oldBox, isShiftPressing, isAltPressing) : getResizedRect(type, newPos, oldBox, isShiftPressing, isAltPressing); - this.setAttrs(rect); + this.updateAttrs(rect); } draw( // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -356,15 +342,16 @@ export class Graph { stroke: string, strokeWidth: number, ) { + const { x, y, width, height } = this.getRect(); if (this.rotation) { - const cx = this.x + this.width / 2; - const cy = this.y + this.height / 2; + const cx = x + width / 2; + const cy = y + height / 2; rotateInCanvas(ctx, this.rotation, cx, cy); } ctx.strokeStyle = stroke; ctx.lineWidth = strokeWidth; ctx.beginPath(); - ctx.rect(this.x, this.y, this.width, this.height); + ctx.rect(x, y, width, height); ctx.stroke(); ctx.closePath(); } @@ -386,8 +373,7 @@ export class Graph { cornerRadius = 0, ) { const src = texture.attrs.src; - const width = this.width; - const height = this.height; + const { x, y, width, height } = this.getRect(); let img: CanvasImageSource | undefined = undefined; // anti-aliasing @@ -413,14 +399,7 @@ export class Graph { if (cornerRadius) { ctx.save(); - drawRoundRectPath( - ctx, - this.x, - this.y, - this.width, - this.height, - cornerRadius, - ); + drawRoundRectPath(ctx, x, y, width, height, cornerRadius); ctx.clip(); } @@ -430,8 +409,8 @@ export class Graph { sy, width / scale, height / scale, - this.x, - this.y, + x, + y, width, height, ); @@ -503,8 +482,10 @@ export class Graph { center.y, ); - this.x = x - initAttrs.width / 2; - this.y = y - initAttrs.height / 2; + this.updateAttrs({ + x: x - initAttrs.width / 2, + y: y - initAttrs.height / 2, + }); } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/core/src/graphs/index.ts b/packages/core/src/graphs/index.ts index 176e8f10..761410a5 100644 --- a/packages/core/src/graphs/index.ts +++ b/packages/core/src/graphs/index.ts @@ -2,5 +2,6 @@ export * from './ellipse'; export * from './graph'; export * from './group'; export * from './line'; +export * from './path'; export * from './rect'; export * from './text'; diff --git a/packages/core/src/graphs/path.ts b/packages/core/src/graphs/path.ts new file mode 100644 index 00000000..45087449 --- /dev/null +++ b/packages/core/src/graphs/path.ts @@ -0,0 +1,176 @@ +import { parseRGBAStr } from '@suika/common'; +import { + addPoint, + getRectByPoints, + IPoint, + IRect, + IRectWithRotation, +} from '@suika/geo'; + +import { ImgManager } from '../Img_manager'; +import { TextureType } from '../texture'; +import { GraphType } from '../type'; +import { rotateInCanvas } from '../utils'; +import { Graph, GraphAttrs } from './graph'; + +export interface ISegment { + point: IPoint; + /** the coordinates relative to point */ + handleIn: IPoint; + /** the coordinates relative to point */ + handleOut: IPoint; +} + +export interface PathAttrs extends GraphAttrs { + pathData?: ISegment[][]; +} + +export class Path extends Graph { + pathData: ISegment[][]; + + constructor(options: PathAttrs) { + super({ ...options, type: GraphType.Path }); + this.pathData = options.pathData ?? []; + } + + override getBBox(): IRect { + const points: IPoint[] = []; + const pathData = this.pathData; + for (const path of pathData) { + for (const seg of path) { + points.push(seg.point, Path.getHandleIn(seg), Path.getHandleOut(seg)); + } + } + const rect = getRectByPoints(points); + return rect; + } + + override getRectWithRotation(): IRectWithRotation { + return { ...this.getBBox(), rotation: this.rotation }; + } + + override updateAttrs(attrs: Partial) { + if (attrs.pathData) { + this.pathData = attrs.pathData; + } + if (attrs.x !== undefined) { + // move all points in pathData + const originX = this.getRect().x; + const dx = attrs.x - originX; + const pathData = this.pathData; + for (const pathItem of pathData) { + for (const seg of pathItem) { + seg.point.x += dx; + } + } + } + if (attrs.y !== undefined) { + const originY = this.getRect().y; + const dy = attrs.y - originY; + const pathData = this.pathData; + for (const pathItem of pathData) { + for (const seg of pathItem) { + seg.point.y += dy; + } + } + } + super.updateAttrs(attrs); + } + + override getRect() { + return this.getBBox(); + } + + override draw( + ctx: CanvasRenderingContext2D, + imgManager?: ImgManager | undefined, + smooth?: boolean | undefined, + ) { + if (this.rotation) { + const { x: cx, y: cy } = this.getCenter(); + + rotateInCanvas(ctx, this.rotation, cx, cy); + } + + ctx.beginPath(); + for (const path of this.pathData) { + const first = path[0]; + ctx.moveTo(first.point.x, first.point.y); + for (let i = 1; i < path.length; i++) { + const currSeg = path[i]; + const prevSeg = path[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) { + ctx.lineTo(pointX, pointY); + } else { + ctx.bezierCurveTo( + handle1.x, + handle1.y, + handle2.x, + handle2.y, + pointX, + pointY, + ); + } + } + } + + for (const texture of this.fill) { + switch (texture.type) { + case TextureType.Solid: { + ctx.fillStyle = parseRGBAStr(texture.attrs); + ctx.fill(); + break; + } + case TextureType.Image: { + if (imgManager) { + ctx.clip(); + this.fillImage(ctx, texture, imgManager, smooth); + } else { + console.warn('ImgManager is not provided'); + } + } + } + } + if (this.strokeWidth) { + ctx.lineWidth = this.strokeWidth; + for (const texture of this.stroke) { + switch (texture.type) { + case TextureType.Solid: { + ctx.strokeStyle = parseRGBAStr(texture.attrs); + ctx.stroke(); + break; + } + case TextureType.Image: { + // TODO: stroke image + } + } + } + } + ctx.closePath(); + } + + override toJSON() { + return { + ...super.toJSON(), + pathData: this.pathData, + }; + } + + override getAttrs(): PathAttrs { + return { + ...super.getAttrs(), + pathData: this.pathData, + }; + } + + static getHandleIn(seg: ISegment) { + return addPoint(seg.point, seg.handleIn); + } + static getHandleOut(seg: ISegment) { + return addPoint(seg.point, seg.handleOut); + } +} diff --git a/packages/core/src/host_event_manager/command_key_binding.ts b/packages/core/src/host_event_manager/command_key_binding.ts index 1435dffa..03a2bdd3 100644 --- a/packages/core/src/host_event_manager/command_key_binding.ts +++ b/packages/core/src/host_event_manager/command_key_binding.ts @@ -62,16 +62,21 @@ export class CommandKeyBinding { action: selectAllAction, }); - // cancel select - const cancelSelectAction = () => { - editor.selectedElements.clear(); + // switch to default select tool + // or cancel select(when in select tool) + const setDefaultToolOrCancelSelectAction = () => { + if (this.editor.toolManager.getActiveToolName() === 'select') { + editor.selectedElements.clear(); + } else { + this.editor.toolManager.setActiveTool('select'); + } editor.sceneGraph.render(); }; editor.keybindingManager.register({ key: { keyCode: 'Escape' }, when: (ctx) => !ctx.isToolDragging, actionName: 'Cancel Select', - action: cancelSelectAction, + action: setDefaultToolOrCancelSelectAction, }); /********** Ruler **********/ diff --git a/packages/core/src/host_event_manager/move_graphs_key_binding.ts b/packages/core/src/host_event_manager/move_graphs_key_binding.ts index 55297198..d3d0e694 100644 --- a/packages/core/src/host_event_manager/move_graphs_key_binding.ts +++ b/packages/core/src/host_event_manager/move_graphs_key_binding.ts @@ -1,6 +1,6 @@ import { arrMap, debounce, noop } from '@suika/common'; -import { SetElementsAttrs } from '../commands/set_elements_attrs'; +import { SetGraphsAttrsCmd } from '../commands/set_elements_attrs'; import { Editor } from '../editor'; import { Graph } from '../graphs'; import { IPoint } from '../type'; @@ -32,7 +32,7 @@ export class MoveGraphsKeyBinding { isEnableUpdateStartPoints = true; this.editor.commandManager.enableRedoUndo(); editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( 'Move elements', moveEls, arrMap(moveEls, ({ x, y }) => ({ x, y })), diff --git a/packages/core/src/key_binding_manager.ts b/packages/core/src/key_binding_manager.ts index b9f84607..a8448aa4 100644 --- a/packages/core/src/key_binding_manager.ts +++ b/packages/core/src/key_binding_manager.ts @@ -69,7 +69,7 @@ export class KeyBindingManager { let isMatch = false; const ctx: IWhenCtx = { - isToolDragging: this.editor.toolManager.isDragging, + isToolDragging: this.editor.toolManager.isDragging(), }; for (const keyBinding of this.keyBindingMap.values()) { diff --git a/packages/core/src/path_editor/path_editor.ts b/packages/core/src/path_editor/path_editor.ts new file mode 100644 index 00000000..09ee0e5c --- /dev/null +++ b/packages/core/src/path_editor/path_editor.ts @@ -0,0 +1,203 @@ +import { parseHexToRGBA } from '@suika/common'; +import { getRotatedRectByTwoPoint, IPoint, isPointEqual } from '@suika/geo'; + +import { ControlHandle } from '../control_handle_manager'; +import { Editor } from '../editor'; +import { Ellipse, Graph, Line, Path, Rect } from '../graphs'; +import { TextureType } from '../texture'; + +export class PathEditor { + private _active = false; + private path: Path | null = null; + private eventTokens: number[] = []; + constructor(private editor: Editor) {} + + private onSelectedChange = (items: Graph[]) => { + if (items.length === 0 && items[0] === this.path) { + return; + } + // end path edit + this.inactive(); + this.editor.toolManager.setActiveTool('select'); + }; + + getActive() { + return this._active; + } + active(path: Path) { + this._active = true; + this.path = path; + + this.editor.sceneGraph.showSelectedGraphsOutline = false; + this.editor.sceneGraph.highlightLayersOnHover = false; + + this.bindHotkeys(); + this.editor.selectedElements.on('itemsChange', this.onSelectedChange); + // 监听 + } + inactive() { + this._active = false; + this.path = null; + this.editor.sceneGraph.showSelectedGraphsOutline = true; + this.editor.sceneGraph.highlightLayersOnHover = true; + this.editor.controlHandleManager.clearCustomHandles(); + + this.unbindHotkeys(); + this.editor.selectedElements.off('itemsChange', this.onSelectedChange); + } + + private bindHotkeys() { + const editor = this.editor; + + // delete / backspace: delete selected segments + const token = editor.keybindingManager.registerWithHighPrior({ + key: [{ keyCode: 'Backspace' }, { keyCode: 'Delete' }], + when: (ctx) => !ctx.isToolDragging, + actionName: 'Path Delete', + action: () => { + // TODO: ... + }, + }); + this.eventTokens.push(token); + + // TODO: + // esc: finish current path edit + // enter: end path + } + + private unbindHotkeys() { + for (const token of this.eventTokens) { + this.editor.keybindingManager.unregister(token); + } + } + + /** + * get anchor and control handles + */ + getControlHandles( + path: Path | null, + activePos: { path: number; seg: number[] }[], + ): ControlHandle[] { + if (!path) { + return []; + } + const handleStroke = this.editor.setting.get('handleStroke'); + const zoom = this.editor.zoomManager.getZoom(); + const pathData = path.pathData; + + const controlHandles: ControlHandle[] = []; + + for (const pathDataItem of pathData) { + for (const seg of pathDataItem) { + const anchor = seg.point; + // 1. draw anchor + const anchorControlHandle = new ControlHandle({ + cx: anchor.x, + cy: anchor.y, + type: 'anchor', + graph: new Ellipse({ + x: anchor.x, + y: anchor.y, + width: 6, + height: 6, + fill: [ + { + type: TextureType.Solid, + attrs: parseHexToRGBA('#fff')!, + }, + ], + stroke: [ + { + type: TextureType.Solid, + attrs: parseHexToRGBA(handleStroke)!, + }, + ], + strokeWidth: 1, + }), + getCursor: () => 'default', + }); + controlHandles.push(anchorControlHandle); + } + } + + for (const pos of activePos) { + const pathItem = pathData[pos.path]; + if (!pathItem) { + console.warn('pathItem not found', pos.path); + continue; + } + for (const segIndex of pos.seg) { + const seg = pathItem[segIndex]; + if (!seg) { + console.warn('seg not found', segIndex); + continue; + } + const anchor = seg.point; + + // 2. draw handleLine and handlePoint + const handles: IPoint[] = []; + const handleIn = Path.getHandleIn(seg); + const handleOut = Path.getHandleOut(seg); + !isPointEqual(handleIn, anchor) && handles.push(handleOut); + !isPointEqual(handleOut, anchor) && handles.push(handleIn); + + for (let i = 0; i < handles.length; i++) { + const handle = handles[i]; + + const rect = getRotatedRectByTwoPoint(anchor, handle); + const handleLine = new ControlHandle({ + cx: rect.x + rect.width / 2, + cy: rect.y + rect.height / 2, + type: 'handleLine', + rotation: rect.rotation, + graph: new Line({ + ...rect, + width: rect.width * zoom, + stroke: [ + { + type: TextureType.Solid, + attrs: parseHexToRGBA(handleStroke)!, + }, + ], + strokeWidth: 1, + }), + getCursor: () => 'default', + }); + + const QUARTER_PI = Math.PI / 4; + const handlePoint = new ControlHandle({ + cx: handle.x, + cy: handle.y, + rotation: QUARTER_PI, + type: 'handle', + graph: new Rect({ + x: handle.x, + y: handle.y, + width: 4, + height: 4, + fill: [ + { + type: TextureType.Solid, + attrs: parseHexToRGBA('#fff')!, + }, + ], + stroke: [ + { + type: TextureType.Solid, + attrs: parseHexToRGBA(handleStroke)!, + }, + ], + strokeWidth: 1, + }), + getCursor: () => 'default', + }); + + controlHandles.push(handleLine); + controlHandles.push(handlePoint); + } + } + } + + return controlHandles; + } +} diff --git a/packages/core/src/scene/scene_graph.ts b/packages/core/src/scene/scene_graph.ts index 088863cf..df3a46c9 100644 --- a/packages/core/src/scene/scene_graph.ts +++ b/packages/core/src/scene/scene_graph.ts @@ -7,7 +7,15 @@ import { import { IRect, isRectIntersect } from '@suika/geo'; import { Editor } from '../editor'; -import { Ellipse, Graph, GraphAttrs, Line, Rect, TextGraph } from '../graphs'; +import { + Ellipse, + Graph, + GraphAttrs, + Line, + Path, + Rect, + TextGraph, +} from '../graphs'; import Grid from '../grid'; import { GraphType, IEditorPaperData, IObject } from '../type'; import { rafThrottle } from '../utils'; @@ -18,6 +26,7 @@ const graphCtorMap = { [GraphType.Ellipse]: Ellipse, [GraphType.Line]: Line, [GraphType.Text]: TextGraph, + [GraphType.Path]: Path, }; interface Events { @@ -34,7 +43,9 @@ export class SceneGraph { } | null = null; private eventEmitter = new EventEmitter(); private grid: Grid; - showOutline = true; + showBoxAndHandleWhenSelected = true; + showSelectedGraphsOutline = true; + highlightLayersOnHover = true; constructor(private editor: Editor) { this.grid = new Grid(editor); @@ -144,7 +155,7 @@ export class SceneGraph { } /** draw hover graph outline and its control handle */ - if (setting.get('highlightLayersOnHover')) { + if (this.highlightLayersOnHover && setting.get('highlightLayersOnHover')) { const hlItem = selectedElements.getHighlightedItem(); if (hlItem && !selectedElements.hasItem(hlItem)) { this.drawGraphsOutline( @@ -157,7 +168,7 @@ export class SceneGraph { const selectedRect = this.editor.selectedBox.updateBbox(); /** draw selected elements outline */ - if (this.showOutline) { + if (this.showSelectedGraphsOutline) { this.drawGraphsOutline( this.editor.selectedElements .getItems() @@ -168,16 +179,16 @@ export class SceneGraph { } /** draw transform handle */ - if (this.showOutline) { + if (this.showBoxAndHandleWhenSelected) { // rect + rotation - if (selectedRect) { - this.editor.controlHandleManager.draw({ - ...selectedRect, - rotation: this.editor.selectedElements.getRotation(), - }); - } else { - this.editor.controlHandleManager.inactive(); - } + this.editor.controlHandleManager.draw( + selectedRect + ? { + ...selectedRect, + rotation: this.editor.selectedElements.getRotation(), + } + : null, + ); } /** draw selection */ diff --git a/packages/core/src/selected_elements.ts b/packages/core/src/selected_elements.ts index 188ae380..709a9309 100644 --- a/packages/core/src/selected_elements.ts +++ b/packages/core/src/selected_elements.ts @@ -1,8 +1,8 @@ import { EventEmitter, isSameArray } from '@suika/common'; import { getMergedRect } from '@suika/geo'; -import { GroupElements } from './commands/group'; -import { RemoveElement } from './commands/remove_element'; +import { GroupCmd } from './commands/group'; +import { RemoveGraphsCmd } from './commands/remove_graphs'; import { Editor } from './editor'; import { Graph } from './graphs'; import { IBox } from './type'; @@ -25,9 +25,7 @@ class SelectedElements { setItems(items: Graph[]) { const prevItems = this.items; this.items = items; - if (!isSameArray(prevItems, items)) { - this.eventEmitter.emit('itemsChange', items); - } + this.emitItemsChangeIfChanged(prevItems, items); } getItems({ excludeLocked = false } = {}): Graph[] { if (excludeLocked) { @@ -60,8 +58,7 @@ class SelectedElements { if (this.hoverItem && this.items.includes(this.hoverItem)) { this.setHoverItem(null); } - this.items = []; - this.eventEmitter.emit('itemsChange', this.items); + this.setItems([]); } /** * “追加” 多个元素 @@ -82,8 +79,11 @@ class SelectedElements { retItems.push(...toggledElements); this.items = retItems; - if (!isSameArray(prevItems, retItems)) { - this.eventEmitter.emit('itemsChange', this.items); + this.emitItemsChangeIfChanged(prevItems, retItems); + } + private emitItemsChangeIfChanged(prevItems: Graph[], items: Graph[]) { + if (!isSameArray(prevItems, items)) { + this.eventEmitter.emit('itemsChange', items); } } toggleItemById(id: string) { @@ -134,7 +134,7 @@ class SelectedElements { return; } this.editor.commandManager.pushCommand( - new RemoveElement('Remove Elements', this.editor, this.items), + new RemoveGraphsCmd('Remove Elements', this.editor, this.items), ); this.editor.sceneGraph.render(); } @@ -151,7 +151,7 @@ class SelectedElements { return; } this.editor.commandManager.pushCommand( - new GroupElements('Group Elements', this.editor, this.items), + new GroupCmd('Group Elements', this.editor, this.items), ); } diff --git a/packages/core/src/service/mutate_graphs_and_record.ts b/packages/core/src/service/mutate_graphs_and_record.ts index 2315e43a..83b3fd74 100644 --- a/packages/core/src/service/mutate_graphs_and_record.ts +++ b/packages/core/src/service/mutate_graphs_and_record.ts @@ -1,6 +1,6 @@ import { getRectRotatedXY } from '@suika/geo'; -import { SetElementsAttrs } from '../commands/set_elements_attrs'; +import { SetGraphsAttrsCmd } from '../commands/set_elements_attrs'; import { Editor } from '../editor'; import { Graph } from '../graphs'; @@ -20,7 +20,7 @@ export const MutateGraphsAndRecord = { element.setRotatedX(rotatedX); } editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( 'Update X of Elements', elements, elements.map((el) => ({ x: el.x })), @@ -39,7 +39,7 @@ export const MutateGraphsAndRecord = { element.setRotatedY(rotatedY); } editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( 'Update Y of Elements', elements, elements.map((el) => ({ y: el.y })), @@ -67,7 +67,7 @@ export const MutateGraphsAndRecord = { el.y -= dy; }); editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( 'Update Width of Elements', elements, elements.map((el) => ({ width: el.width, x: el.x, y: el.y })), @@ -95,7 +95,7 @@ export const MutateGraphsAndRecord = { el.y -= dy; }); editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( 'update Height of Elements', elements, elements.map((el) => ({ height: el.height, x: el.x, y: el.y })), @@ -113,7 +113,7 @@ export const MutateGraphsAndRecord = { el.rotation = rotation; }); editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( 'update Rotation', elements, { rotation }, @@ -133,7 +133,7 @@ export const MutateGraphsAndRecord = { el.cornerRadius = cornerRadius; }); editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( 'update Corner Radius', elements, { cornerRadius }, @@ -159,7 +159,7 @@ export const MutateGraphsAndRecord = { el.visible = newVisible; }); editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( 'update visible of graphs', graphs, { visible: newVisible }, @@ -182,7 +182,7 @@ export const MutateGraphsAndRecord = { el.lock = newLock; }); editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( 'update lock of graphs', graphs, { lock: newLock }, @@ -196,7 +196,7 @@ export const MutateGraphsAndRecord = { const prevAttrs = [{ objectName: graph.objectName }]; graph.objectName = objectName; editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( 'update name of graph', [graph], { objectName }, diff --git a/packages/core/src/text/text_editor.ts b/packages/core/src/text/text_editor.ts index 3c0264fd..9110bbda 100644 --- a/packages/core/src/text/text_editor.ts +++ b/packages/core/src/text/text_editor.ts @@ -1,6 +1,6 @@ import { cloneDeep } from '@suika/common'; -import { AddShapeCommand } from '../commands/add_shape'; +import { AddGraphCmd } from '../commands/add_graphs'; import { Editor } from '../editor'; import { TextGraph } from '../graphs'; @@ -68,7 +68,7 @@ export class TextEditor { } this.editor.commandManager.pushCommand( - new AddShapeCommand('draw text', this.editor, [text]), + new AddGraphCmd('draw text', this.editor, [text]), ); } visible(x: number, y: number) { diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts new file mode 100644 index 00000000..040524c9 --- /dev/null +++ b/packages/core/src/tools/index.ts @@ -0,0 +1 @@ +export * from './tool_manager'; diff --git a/packages/core/src/tools/tool_drag_canvas.ts b/packages/core/src/tools/tool_drag_canvas.ts index e011e825..3220ab6a 100644 --- a/packages/core/src/tools/tool_drag_canvas.ts +++ b/packages/core/src/tools/tool_drag_canvas.ts @@ -2,14 +2,15 @@ import { ICursor } from '../cursor_manager'; import { Editor } from '../editor'; import { ITool } from './type'; -/** - * drag canvas - */ +const TYPE = 'dragCanvas'; +const HOTKEY = 'h'; + export class DragCanvasTool implements ITool { - static type = 'dragCanvas'; + static readonly type = TYPE; + static readonly hotkey = HOTKEY; + readonly type = TYPE; + readonly hotkey = HOTKEY; cursor: ICursor = 'grab'; - readonly type = 'dragCanvas'; - readonly hotkey = 'h'; constructor(private editor: Editor) {} active() { diff --git a/packages/core/src/tools/tool_draw_graph.ts b/packages/core/src/tools/tool_draw_graph.ts index afd131f8..99ff0a52 100644 --- a/packages/core/src/tools/tool_draw_graph.ts +++ b/packages/core/src/tools/tool_draw_graph.ts @@ -1,7 +1,7 @@ import { noop } from '@suika/common'; import { IPoint, IRect, normalizeRect } from '@suika/geo'; -import { AddShapeCommand } from '../commands/add_shape'; +import { AddGraphCmd } from '../commands/add_graphs'; import { ICursor } from '../cursor_manager'; import { Editor } from '../editor'; import { Graph } from '../graphs'; @@ -9,15 +9,15 @@ import { ITool } from './type'; /** * Draw Graph Tool - * * reference: https://mp.weixin.qq.com/s/lD1qlGus3pRvT5ZfdH0_lg */ export abstract class DrawGraphTool implements ITool { - static type = 'drawGraph'; + static readonly type: string = ''; + static readonly hotkey: string = ''; + readonly type: string = ''; + readonly hotkey: string = ''; cursor: ICursor = 'crosshair'; - type = 'drawGraph'; commandDesc = 'Add Graph'; - hotkey = ''; protected drawingGraph: Graph | null = null; @@ -135,10 +135,13 @@ export abstract class DrawGraphTool implements ITool { protected updateGraph(rect: IRect) { rect = normalizeRect(rect); const drawingShape = this.drawingGraph!; - drawingShape.x = rect.x; - drawingShape.y = rect.y; - drawingShape.width = rect.width; - drawingShape.height = rect.height; + + drawingShape.updateAttrs({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }); } /** update drawing rect object */ @@ -245,7 +248,7 @@ export abstract class DrawGraphTool implements ITool { if (this.drawingGraph) { this.editor.commandManager.pushCommand( - new AddShapeCommand(this.commandDesc, this.editor, [this.drawingGraph]), + new AddGraphCmd(this.commandDesc, this.editor, [this.drawingGraph]), ); } } diff --git a/packages/core/src/tools/tool_draw_line.ts b/packages/core/src/tools/tool_draw_line.ts index 20df0704..0a249e41 100644 --- a/packages/core/src/tools/tool_draw_line.ts +++ b/packages/core/src/tools/tool_draw_line.ts @@ -7,10 +7,14 @@ import { adjustSizeToKeepPolarSnap } from '../utils'; import { DrawGraphTool } from './tool_draw_graph'; import { ITool } from './type'; +const TYPE = 'drawLine'; +const HOTKEY = 'l'; + export class DrawLineTool extends DrawGraphTool implements ITool { - static override readonly type = 'drawLine'; - override readonly type = 'drawLine'; - override readonly hotkey = 'l'; + static override readonly type = TYPE; + static override readonly hotkey = HOTKEY; + override readonly type = TYPE; + override readonly hotkey = HOTKEY; constructor(editor: Editor) { super(editor); @@ -37,7 +41,7 @@ export class DrawLineTool extends DrawGraphTool implements ITool { protected override updateGraph(rect: IRect) { const attrs = this.calcAttrs(rect); - Object.assign(this.drawingGraph!, attrs); + this.drawingGraph!.updateAttrs(attrs); } private calcAttrs({ x, y, width, height }: IRect) { diff --git a/packages/core/src/tools/tool_draw_path.ts b/packages/core/src/tools/tool_draw_path.ts new file mode 100644 index 00000000..47504a15 --- /dev/null +++ b/packages/core/src/tools/tool_draw_path.ts @@ -0,0 +1,295 @@ +import { cloneDeep, getClosestTimesVal, parseHexToRGBA } from '@suika/common'; +import { IPoint } from '@suika/geo'; + +import { AddGraphCmd, SetGraphsAttrsCmd } from '../commands'; +import { ControlHandle } from '../control_handle_manager'; +import { ICursor } from '../cursor_manager'; +import { Editor } from '../editor'; +import { Ellipse, ISegment, Path } from '../graphs'; +import { TextureType } from '../texture'; +import { ITool } from './type'; + +const TYPE = 'drawPath'; +const HOTKEY = 'p'; + +export class DrawPathTool implements ITool { + static readonly type = TYPE; + static readonly hotkey = HOTKEY; + readonly type = TYPE; + readonly hotkey = HOTKEY; + cursor: ICursor = 'default'; + + private startPoint: IPoint | null = null; + private path: Path | null = null; + private prevPathData: ISegment[][] = []; + private pathIndex = -1; + private currCursorScenePoint: IPoint | null = null; + + constructor(private editor: Editor) {} + active() { + // noop + } + inactive() { + this.editor.commandManager.batchCommandEnd(); + + this.editor.pathEditor.inactive(); + this.editor.render(); + } + + moveExcludeDrag(e: PointerEvent, isOutsideCanvas: boolean) { + if (isOutsideCanvas) { + return; + } + const point = this.editor.getSceneCursorXY(e); + if (this.editor.setting.get('snapToPixelGrid')) { + point.x = getClosestTimesVal(point.x, 0.5); + point.y = getClosestTimesVal(point.y, 0.5); + } + + this.currCursorScenePoint = point; + if (this.editor.hostEventManager.isSpacePressing) { + this.updateControlHandles(); + } else { + this.updateControlHandlesAndPreviewHandles(this.currCursorScenePoint); + } + } + + start(e: PointerEvent) { + const point = this.editor.getSceneCursorXY(e); + if (this.editor.setting.get('snapToPixelGrid')) { + point.x = getClosestTimesVal(point.x, 0.5); + point.y = getClosestTimesVal(point.y, 0.5); + } + this.startPoint = point; + + if (!this.editor.pathEditor.getActive()) { + this.pathIndex = 0; + + const pathData: ISegment[][] = [ + [ + { + point: { ...point }, + handleIn: { x: 0, y: 0 }, + handleOut: { x: 0, y: 0 }, + }, + ], + ]; + + const path = new Path({ + x: 0, + y: 0, + width: 100, + height: 100, + strokeWidth: 1, + stroke: [ + { + type: TextureType.Solid, + attrs: { + r: 0, + g: 0, + b: 0, + a: 1, + }, + }, + ], + pathData, + }); + this.path = path; + + this.editor.sceneGraph.addItems([path]); + this.editor.commandManager.batchCommandStart(); + this.editor.commandManager.pushCommand( + new AddGraphCmd('add path', this.editor, [path]), + ); + this.editor.selectedElements.setItems([path]); + + this.editor.pathEditor.active(path); + } else { + this.prevPathData = this.path!.pathData; + const pathData = cloneDeep(this.path!.pathData); + this.path?.updateAttrs({ + pathData, + }); + const pathDataItem = this.path!.pathData[this.pathIndex]; + pathDataItem.push({ + point, + handleIn: { x: 0, y: 0 }, + handleOut: { x: 0, y: 0 }, + }); + } + + this.updateControlHandles(); + this.editor.render(); + } + + drag(e: PointerEvent) { + if (!this.startPoint) { + console.warn('startPoint is null, check start()'); + return; + } + + const point = this.editor.getSceneCursorXY(e); + if (this.editor.setting.get('snapToPixelGrid')) { + point.x = getClosestTimesVal(point.x, 0.5); + point.y = getClosestTimesVal(point.y, 0.5); + } + + const dx = point.x - this.startPoint.x; + const dy = point.y - this.startPoint.y; + + const path = this.path!; + const currPath = path.pathData[this.pathIndex]; + const lastSeg = currPath.at(-1)!; + // mirror angle and length + lastSeg.handleOut = { x: dx, y: dy }; + lastSeg.handleIn = { x: -dx, y: -dy }; + + this.updateControlHandles(); + this.editor.render(); + } + + end() { + this.editor.commandManager.pushCommand( + new SetGraphsAttrsCmd( + 'update path data', + [this.path!], + [{ pathData: this.path!.pathData }], + [{ pathData: this.prevPathData }], + ), + ); + this.editor.commandManager.batchCommandEnd(); + } + + afterEnd() { + // noop + } + + onCommandChange() { + if (this.currCursorScenePoint) { + this.updateControlHandlesAndPreviewHandles(this.currCursorScenePoint); + } + } + + onSpaceToggle(isSpacePressing: boolean) { + if (isSpacePressing) { + this.updateControlHandles(); + this.editor.render(); + } else { + if (this.currCursorScenePoint) { + this.updateControlHandlesAndPreviewHandles(this.currCursorScenePoint); + this.editor.render(); + } + } + } + + onViewportXOrYChange() { + if (this.editor.hostEventManager.isSpacePressing) { + this.updateControlHandles(); + } else { + this.updateControlHandlesAndPreviewHandles(this.currCursorScenePoint!); + } + this.editor.render(); + } + + private updateControlHandlesAndPreviewHandles(point: IPoint) { + const previewHandles: ControlHandle[] = []; + + const lastSeg = this.path?.pathData[this.pathIndex]?.at(-1); + if (lastSeg) { + const previewCurve = new ControlHandle({ + cx: point.x, + cy: point.y, + type: 'path-preview-curve', + getCursor: () => 'default', + graph: new Path({ + x: 0, + y: 0, + width: 0, + height: 0, + pathData: [ + [ + { + point: this.editor.sceneCoordsToViewport( + lastSeg.point.x, + lastSeg.point.y, + ), + handleIn: { + x: this.editor.sceneSizeToViewport(lastSeg.handleIn.x), + y: this.editor.sceneSizeToViewport(lastSeg.handleIn.y), + }, + handleOut: { + x: this.editor.sceneSizeToViewport(lastSeg.handleOut.x), + y: this.editor.sceneSizeToViewport(lastSeg.handleOut.y), + }, + }, + { + point: this.editor.sceneCoordsToViewport(point.x, point.y), + handleIn: { x: 0, y: 0 }, + handleOut: { x: 0, y: 0 }, + }, + ], + ], + stroke: [ + { + type: TextureType.Solid, + attrs: parseHexToRGBA('#1592fe')!, + }, + ], + strokeWidth: 1, + }), + }); + previewHandles.push(previewCurve); + } + + const handleStroke = this.editor.setting.get('handleStroke'); + + const previewPoint = new ControlHandle({ + cx: point.x, + cy: point.y, + type: 'path-preview-anchor', + getCursor: () => 'default', + graph: new Ellipse({ + x: point.x, + y: point.y, + width: 6, + height: 6, + fill: [ + { + type: TextureType.Solid, + attrs: parseHexToRGBA('#fff')!, + }, + ], + stroke: [ + { + type: TextureType.Solid, + attrs: parseHexToRGBA(handleStroke)!, + }, + ], + strokeWidth: 1, + }), + }); + previewHandles.push(previewPoint); + + this.updateControlHandles(previewHandles); + this.editor.render(); + } + + private updateControlHandles(controlHandles: ControlHandle[] = []) { + const path = this.path!; + if (this.pathIndex !== -1) { + const currPath: ISegment[] | undefined = path.pathData[this.pathIndex]; + + const segIndies = currPath ? [currPath.length - 1] : []; + if (currPath && currPath.length > 1) { + segIndies.push(currPath.length - 2); + } + + controlHandles = this.editor.pathEditor + .getControlHandles(path, [{ path: this.pathIndex, seg: segIndies }]) + .concat(controlHandles); + } + + this.editor.controlHandleManager.setCustomHandles(controlHandles); + this.editor.controlHandleManager.showCustomHandles(); + } +} diff --git a/packages/core/src/tools/tool_draw_rect.ts b/packages/core/src/tools/tool_draw_rect.ts index 1182824f..92caefd6 100644 --- a/packages/core/src/tools/tool_draw_rect.ts +++ b/packages/core/src/tools/tool_draw_rect.ts @@ -6,10 +6,14 @@ import { Rect } from '../graphs'; import { DrawGraphTool } from './tool_draw_graph'; import { ITool } from './type'; +const TYPE = 'drawRect'; +const HOTKEY = 'r'; + export class DrawRectTool extends DrawGraphTool implements ITool { - static override readonly type = 'drawRect'; - override readonly type = 'drawRect'; - override readonly hotkey = 'r'; + static override readonly type = TYPE; + static override readonly hotkey = HOTKEY; + override readonly type = TYPE; + override readonly hotkey = HOTKEY; constructor(editor: Editor) { super(editor); diff --git a/packages/core/src/tools/tool_draw_text.ts b/packages/core/src/tools/tool_draw_text.ts index e403fd4d..f6c08cc1 100644 --- a/packages/core/src/tools/tool_draw_text.ts +++ b/packages/core/src/tools/tool_draw_text.ts @@ -2,11 +2,15 @@ import { ICursor } from '../cursor_manager'; import { Editor } from '../editor'; import { ITool } from './type'; +const TYPE = 'drawText'; +const HOTKEY = 't'; + export class DrawTextTool implements ITool { - static readonly type = 'drawText'; + static readonly type = TYPE; + static readonly hotkey = HOTKEY; + readonly type = TYPE; + readonly hotkey = HOTKEY; cursor: ICursor = 'crosshair'; - readonly type = 'drawText'; - readonly hotkey = 't'; constructor(private editor: Editor) {} active() { diff --git a/packages/core/src/tools/tool_manager.ts b/packages/core/src/tools/tool_manager.ts index 4bfd7e94..cbcff91f 100644 --- a/packages/core/src/tools/tool_manager.ts +++ b/packages/core/src/tools/tool_manager.ts @@ -1,13 +1,15 @@ import { EventEmitter, noop } from '@suika/common'; +import { IPoint } from '@suika/geo'; import { Editor } from '../editor'; import { DragCanvasTool } from './tool_drag_canvas'; import { DrawEllipseTool } from './tool_draw_ellipse'; import { DrawLineTool } from './tool_draw_line'; +import { DrawPathTool } from './tool_draw_path'; import { DrawRectTool } from './tool_draw_rect'; import { DrawTextTool } from './tool_draw_text'; import { SelectTool } from './tool_select'; -import { ITool } from './type'; +import { ITool, IToolClassConstructor } from './type'; interface Events { change(type: string): void; @@ -15,65 +17,104 @@ interface Events { /** * Tool Manager - * * reference: https://mp.weixin.qq.com/s/ZkZZoscN6N7_ykhC9rOpdQ */ export class ToolManager { - toolMap = new Map(); - /** - * hotkey => tool type - */ - hotkeyMap = new Map(); - - currentTool: ITool | null = null; - eventEmitter = new EventEmitter(); - - enableSwitchTool = true; + /** tool type(string) => tool class constructor */ + private toolCtorMap = new Map(); + /** hotkey => tool type */ + private hotkeyMap = new Map(); + private currentTool: ITool | null = null; + private eventEmitter = new EventEmitter(); + private enableSwitchTool = true; + private keyBindingToken: number[] = []; + private _isDragging = false; + _unbindEvent: () => void; - isDragging = false; + isDragging() { + return this._isDragging; + } - _unbindEvent: () => void; constructor(private editor: Editor) { - this.registerToolAndHotKey(new SelectTool(editor)); - this.registerToolAndHotKey(new DrawRectTool(editor)); - this.registerToolAndHotKey(new DrawEllipseTool(editor)); - this.registerToolAndHotKey(new DrawLineTool(editor)); - this.registerToolAndHotKey(new DrawTextTool(editor)); - this.registerToolAndHotKey(new DragCanvasTool(editor)); + this.registerToolCtorAndHotKey(SelectTool); + this.registerToolCtorAndHotKey(DrawRectTool); + this.registerToolCtorAndHotKey(DrawEllipseTool); + this.registerToolCtorAndHotKey(DrawPathTool); + this.registerToolCtorAndHotKey(DrawLineTool); + this.registerToolCtorAndHotKey(DrawTextTool); + this.registerToolCtorAndHotKey(DragCanvasTool); this.setActiveTool(SelectTool.type); this._unbindEvent = this.bindEvent(); + + this.setEnableHotKeyTools([ + SelectTool.type, + DrawRectTool.type, + DrawEllipseTool.type, + DrawPathTool.type, + DrawLineTool.type, + DrawTextTool.type, + DragCanvasTool.type, + ]); } - registerToolAndHotKey(tool: ITool) { - if (this.toolMap.has(tool.type)) { - console.warn(`tool "${tool.type}" had exit, replace it!`); + private unbindHotkey() { + this.keyBindingToken.forEach((token) => { + this.editor.keybindingManager.unregister(token); + }); + } + private setEnableHotKeyTools(toolType: string[]) { + this.unbindHotkey(); + for (const type of toolType) { + const toolCtor = this.toolCtorMap.get(type); + + if (!toolCtor) { + console.warn( + `tool "${type}" not found, please register it before use it`, + ); + continue; + } + + const key = `Key${toolCtor.hotkey.toUpperCase()}`; + const token = this.editor.keybindingManager.register({ + key: { keyCode: key }, + actionName: type, + action: () => { + this.setActiveTool(type); + }, + }); + this.keyBindingToken.push(token); + } + } + + private registerToolCtorAndHotKey(toolCtor: IToolClassConstructor) { + if (this.toolCtorMap.has(toolCtor.type)) { + console.warn(`tool "${toolCtor.type}" had exit, replace it!`); } - this.toolMap.set(tool.type, tool); + this.toolCtorMap.set(toolCtor.type, toolCtor); - if (this.hotkeyMap.has(tool.hotkey)) { - console.warn(`hotkey "${tool.type}" had exit, replace it!`); + if (this.hotkeyMap.has(toolCtor.hotkey)) { + console.warn(`hotkey "${toolCtor.type}" had exit, replace it!`); } - this.hotkeyMap.set(tool.hotkey, tool.type); + this.hotkeyMap.set(toolCtor.hotkey, toolCtor.type); } getActiveToolName() { return this.currentTool?.type; } /** * bind event - * * about dragBlockStep: https://mp.weixin.qq.com/s/05lbcYIJ8qwP8EHCXzgnqA */ private bindEvent() { // (1) drag block strategy let isPressing = false; - let startPos: [x: number, y: number] = [0, 0]; + let startPos: IPoint = { x: 0, y: 0 }; let startWithLeftMouse = false; const handleDown = (e: PointerEvent) => { setTimeout(() => { isPressing = false; - this.isDragging = false; + this._isDragging = false; startWithLeftMouse = false; if ( e.button !== 0 || // is not left mouse @@ -88,7 +129,7 @@ export class ToolManager { if (!this.currentTool) { throw new Error('there is no active tool'); } - startPos = [e.clientX, e.clientY]; + startPos = { x: e.clientX, y: e.clientY }; this.currentTool.start(e); }); }; @@ -100,16 +141,16 @@ export class ToolManager { if (!startWithLeftMouse) { return; } - const dx = e.clientX - startPos[0]; - const dy = e.clientY - startPos[1]; + const dx = e.clientX - startPos.x; + const dy = e.clientY - startPos.y; const dragBlockStep = this.editor.setting.get('dragBlockStep'); if ( - !this.isDragging && + !this._isDragging && (Math.abs(dx) > dragBlockStep || Math.abs(dy) > dragBlockStep) ) { - this.isDragging = true; + this._isDragging = true; } - if (this.isDragging) { + if (this._isDragging) { this.enableSwitchTool = false; this.editor.canvasDragger.disableDragBySpace(); this.currentTool.drag(e); @@ -132,38 +173,41 @@ export class ToolManager { if (isPressing) { this.editor.canvasDragger.enableDragBySpace(); isPressing = false; - this.currentTool.end(e, this.isDragging); + this.currentTool.end(e, this._isDragging); this.currentTool.afterEnd(e); } - this.isDragging = false; + this._isDragging = false; + }; + const handleCommandChange = () => { + this.currentTool?.onCommandChange?.(); + }; + const handleSpaceToggle = (isSpacePressing: boolean) => { + this.currentTool?.onSpaceToggle?.(isSpacePressing); + }; + const handleViewportXOrYChange = (x: number, y: number) => { + this.currentTool?.onViewportXOrYChange?.(x, y); }; const canvas = this.editor.canvasElement; canvas.addEventListener('pointerdown', handleDown); window.addEventListener('pointermove', handleMove); window.addEventListener('pointerup', handleUp); + this.editor.commandManager.on('change', handleCommandChange); + this.editor.hostEventManager.on('spaceToggle', handleSpaceToggle); + this.editor.viewportManager.on('xOrYChange', handleViewportXOrYChange); - // (2) tool hotkey binding - this.hotkeyMap.forEach((type, key) => { - key = `Key${key.toUpperCase()}`; - this.editor.keybindingManager.register({ - key: { keyCode: key }, - actionName: type, - action: () => { - this.setActiveTool(type); - }, - }); - }); - - return function unbindEvent() { + return () => { canvas.removeEventListener('pointerdown', handleDown); window.removeEventListener('pointermove', handleMove); window.removeEventListener('pointerup', handleUp); + this.editor.commandManager.off('change', handleCommandChange); + this.editor.hostEventManager.off('spaceToggle', handleSpaceToggle); }; } unbindEvent() { this._unbindEvent(); this._unbindEvent = noop; + this.unbindHotkey(); } setActiveTool(toolName: string) { if (!this.enableSwitchTool || this.getActiveToolName() === toolName) { @@ -171,10 +215,12 @@ export class ToolManager { } const prevTool = this.currentTool; - const currentTool = (this.currentTool = this.toolMap.get(toolName) || null); - if (!currentTool) { + const currentToolCtor = this.toolCtorMap.get(toolName) || null; + if (!currentToolCtor) { throw new Error(`没有 ${toolName} 对应的工具对象`); } + const currentTool = (this.currentTool = new currentToolCtor(this.editor)); + prevTool && prevTool.inactive(); this.setCursorWhenActive(); currentTool.active(); diff --git a/packages/core/src/tools/tool_select/tool_select.ts b/packages/core/src/tools/tool_select/tool_select.ts index 00cb0722..6af7479d 100644 --- a/packages/core/src/tools/tool_select/tool_select.ts +++ b/packages/core/src/tools/tool_select/tool_select.ts @@ -10,16 +10,19 @@ import { SelectResizeTool } from './tool_select_resize'; import { SelectRotationTool } from './tool_select_rotation'; import { DrawSelectionBox } from './tool_select_selection'; +const TYPE = 'select'; +const HOTKEY = 'v'; + /** * Select Tool - * * reference: https://mp.weixin.qq.com/s/lXv5_bisMHVHqtv2DwflwA */ export class SelectTool implements ITool { - static type = 'select'; + static readonly type = TYPE; + static readonly hotkey = HOTKEY; + readonly type = TYPE; + readonly hotkey = HOTKEY; cursor: ICursor = 'default'; - type = 'select'; - hotkey = 'v'; private startPoint: IPoint = { x: -1, y: -1 }; private currStrategy: IBaseTool | null = null; @@ -33,8 +36,15 @@ export class SelectTool implements ITool { private topHitElementWhenStart: Graph | null = null; private isDragHappened = false; // 发生过拖拽 + constructor(private editor: Editor) { + this.strategyMove = new SelectMoveTool(editor); + this.strategyDrawSelectionBox = new DrawSelectionBox(editor); + this.strategySelectRotation = new SelectRotationTool(editor); + this.strategySelectResize = new SelectResizeTool(editor); + } + private handleHoverItemChange = () => { - if (!this.editor.toolManager.isDragging) { + if (!this.editor.toolManager.isDragging()) { this.editor.sceneGraph.render(); } }; @@ -46,12 +56,6 @@ export class SelectTool implements ITool { // TODO: resetHoverItem after drag canvas end }; - constructor(private editor: Editor) { - this.strategyMove = new SelectMoveTool(editor); - this.strategyDrawSelectionBox = new DrawSelectionBox(editor); - this.strategySelectRotation = new SelectRotationTool(editor); - this.strategySelectResize = new SelectResizeTool(editor); - } active() { this.editor.selectedElements.on( 'hoverItemChange', 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 3dc29786..cbcbb792 100644 --- a/packages/core/src/tools/tool_select/tool_select_move.ts +++ b/packages/core/src/tools/tool_select/tool_select_move.ts @@ -1,6 +1,6 @@ import { noop } from '@suika/common'; -import { MoveElementsCommand } from '../../commands/move_elements'; +import { MoveGraphsCommand } from '../../commands/move_graphs'; import { Editor } from '../../editor'; import { IPoint } from '../../type'; import { IBaseTool } from '../type'; @@ -43,10 +43,13 @@ export class SelectMoveTool implements IBaseTool { const selectedElements = this.editor.selectedElements.getItems({ excludeLocked: true, }); - this.startPoints = selectedElements.map((element) => ({ - x: element.x, - y: element.y, - })); + this.startPoints = selectedElements.map((graph) => { + const rect = graph.getRect(); + return { + x: rect.x, + y: rect.y, + }; + }); const bBox = this.editor.selectedElements.getBBox(); if (!bBox) { console.error( @@ -63,7 +66,9 @@ export class SelectMoveTool implements IBaseTool { this.move(); } private move() { - this.editor.sceneGraph.showOutline = false; + this.editor.sceneGraph.showBoxAndHandleWhenSelected = false; + this.editor.sceneGraph.showSelectedGraphsOutline = false; + const { x, y } = this.editor.viewportCoordsToScene( this.dragPoint!.x, this.dragPoint!.y, @@ -95,8 +100,10 @@ export class SelectMoveTool implements IBaseTool { }); const startPoints = this.startPoints; for (let i = 0, len = selectedElements.length; i < len; i++) { - selectedElements[i].x = startPoints[i].x + dx; - selectedElements[i].y = startPoints[i].y + dy; + selectedElements[i].updateAttrs({ + x: startPoints[i].x + dx, + y: startPoints[i].y + dy, + }); } // 参照线处理(目前不处理 “吸附到像素网格的情况” 的特殊情况) @@ -104,8 +111,10 @@ export class SelectMoveTool implements IBaseTool { const { offsetX, offsetY } = this.editor.refLine.updateRefLine(); for (let i = 0, len = selectedElements.length; i < len; i++) { - selectedElements[i].x = startPoints[i].x + dx + offsetX; - selectedElements[i].y = startPoints[i].y + dy + offsetY; + selectedElements[i].updateAttrs({ + x: startPoints[i].x + dx + offsetX, + y: startPoints[i].y + dy + offsetY, + }); } this.editor.sceneGraph.render(); @@ -134,12 +143,7 @@ export class SelectMoveTool implements IBaseTool { if (this.dx !== 0 || this.dy !== 0) { this.editor.commandManager.pushCommand( - new MoveElementsCommand( - 'Move Elements', - selectedItems, - this.dx, - this.dy, - ), + new MoveGraphsCommand('Move Elements', selectedItems, this.dx, this.dy), ); // update custom control handles @@ -156,7 +160,8 @@ export class SelectMoveTool implements IBaseTool { afterEnd() { this.dragPoint = null; - this.editor.sceneGraph.showOutline = true; + this.editor.sceneGraph.showBoxAndHandleWhenSelected = true; + this.editor.sceneGraph.showSelectedGraphsOutline = true; this.editor.refLine.clear(); this.editor.sceneGraph.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 222be224..f0e6c1e5 100644 --- a/packages/core/src/tools/tool_select/tool_select_resize.ts +++ b/packages/core/src/tools/tool_select/tool_select_resize.ts @@ -1,7 +1,7 @@ import { arrEvery, arrMap, noop } from '@suika/common'; import { getResizedRect, IRect } from '@suika/geo'; -import { SetElementsAttrs } from '../../commands/set_elements_attrs'; +import { SetGraphsAttrsCmd } from '../../commands/set_elements_attrs'; import { isTransformHandle } from '../../control_handle_manager'; import { Editor } from '../../editor'; import { GraphAttrs } from '../../graphs'; @@ -132,7 +132,7 @@ export class SelectResizeTool implements IBaseTool { const graph = selectItems[i]; const x = newSelectBbox.x + this.graphOffsets[i].x * widthRatio; const y = newSelectBbox.y + this.graphOffsets[i].y * heightRatio; - graph.setAttrs({ + graph.updateAttrs({ x, y, width: this.prevGraphsAttrs[i].width * widthRatio, @@ -149,7 +149,7 @@ export class SelectResizeTool implements IBaseTool { } const items = this.editor.selectedElements.getItems(); this.editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( 'scale select elements', items, arrMap(items, (item) => item.getAttrs()), 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 fda4e9d6..e04efc06 100644 --- a/packages/core/src/tools/tool_select/tool_select_rotation.ts +++ b/packages/core/src/tools/tool_select/tool_select_rotation.ts @@ -1,7 +1,7 @@ import { forEach, getClosestTimesVal } from '@suika/common'; import { normalizeRadian, rad2Deg } from '@suika/geo'; -import { SetElementsAttrs } from '../../commands/set_elements_attrs'; +import { SetGraphsAttrsCmd } from '../../commands/set_elements_attrs'; import { getRotationCursor } from '../../control_handle_manager'; import { Editor } from '../../editor'; import { IPoint } from '../../type'; @@ -53,11 +53,8 @@ export class SelectRotationTool implements IBaseTool { for (let i = 0, len = selectedElements.length; i < len; i++) { const el = selectedElements[i]; this.prevGraphAttrs[i] = { + ...el.getRect(), rotation: el.rotation ?? 0, - x: el.x, - y: el.y, - width: el.width, - height: el.height, }; } @@ -131,7 +128,7 @@ export class SelectRotationTool implements IBaseTool { const commandDesc = 'Rotate Elements'; if (this.dRotation !== 0) { this.editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( commandDesc, selectedElements, selectedElements.map((el) => ({ diff --git a/packages/core/src/tools/type.ts b/packages/core/src/tools/type.ts index 168c6ca3..4e11a686 100644 --- a/packages/core/src/tools/type.ts +++ b/packages/core/src/tools/type.ts @@ -1,4 +1,5 @@ import { ICursor } from '../cursor_manager'; +import { Editor } from '../editor'; export interface ITool extends IBaseTool { hotkey: string; @@ -14,14 +15,23 @@ export interface IBaseTool { start: (event: PointerEvent) => void; drag: (event: PointerEvent) => void; /** - * + * end (after drag) * @param event * @param isDragHappened is drag happened * @returns */ end: (event: PointerEvent, isDragHappened: boolean) => void; - /** - * init state when finish a drag loop - */ + /** init state when finish a drag loop */ afterEnd: (event: PointerEvent) => void; + onCommandChange?: () => void; + /** space key toggle */ + onSpaceToggle?: (isSpacePressing: boolean) => void; + /** viewport x or y change */ + onViewportXOrYChange?: (x: number, y: number) => void; +} + +export interface IToolClassConstructor { + new (editor: Editor): ITool; + type: string; + hotkey: string; } diff --git a/packages/core/src/type.ts b/packages/core/src/type.ts index a3f96bfd..806bc525 100644 --- a/packages/core/src/type.ts +++ b/packages/core/src/type.ts @@ -52,6 +52,7 @@ export enum GraphType { Ellipse = 'Ellipse', Text = 'Text', Line = 'Line', + Path = 'Path', // Group = 'Group', } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 8cedcdd5..ca5db8f9 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2015", "useDefineForClassFields": true, "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ESNext", "ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "node", @@ -18,6 +18,7 @@ "noFallthroughCasesInSwitch": true, "allowSyntheticDefaultImports": true, + "noImplicitOverride": true, "paths": { "@suika/common": ["../common/src"], "@suika/geo": ["../geo/src"] diff --git a/packages/geo/src/geo/geo_point.ts b/packages/geo/src/geo/geo_point.ts index 44b34c0b..7938a31d 100644 --- a/packages/geo/src/geo/geo_point.ts +++ b/packages/geo/src/geo/geo_point.ts @@ -6,3 +6,13 @@ export const getMidPoint = (p1: IPoint, p2: IPoint) => { y: (p1.y + p2.y) / 2, }; }; + +export const addPoint = (p1: IPoint, p2: IPoint) => { + return { + x: p1.x + p2.x, + y: p1.y + p2.y, + }; +}; + +export const isPointEqual = (p1: IPoint, p2: IPoint) => + p1.x === p2.x && p1.y === p2.y; diff --git a/packages/geo/src/geo/geo_rect.ts b/packages/geo/src/geo/geo_rect.ts index 62fe4765..51c3a031 100644 --- a/packages/geo/src/geo/geo_rect.ts +++ b/packages/geo/src/geo/geo_rect.ts @@ -1,5 +1,6 @@ import { transformRotate } from '../transform'; import { IPoint, IRect, IRectWithRotation } from '../type'; +import { normalizeRadian } from './geo_angle'; export const getRectByTwoPoint = (point1: IPoint, point2: IPoint): IRect => { return { @@ -10,6 +11,16 @@ export const getRectByTwoPoint = (point1: IPoint, point2: IPoint): IRect => { }; }; +export const getRectByPoints = (points: IPoint[]): IRect => { + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + const x = Math.min(...xs); + const y = Math.min(...ys); + const width = Math.max(...xs) - x; + const height = Math.max(...ys) - y; + return { x, y, width, height }; +}; + export const isPointInRect = ( point: IPoint, rect: IRectWithRotation, @@ -213,8 +224,29 @@ export const rectToMidPoints = (rect: IRectWithRotation) => { /** * Calculate the coordinates of the upper left corner of a shape, considering rotation */ -export function getRectRotatedXY(rect: IRectWithRotation) { +export const getRectRotatedXY = (rect: IRectWithRotation) => { const cx = rect.x + rect.width / 2; const cy = rect.y + rect.height / 2; return transformRotate(rect.x, rect.y, rect.rotation || 0, cx, cy); -} +}; + +export const getRotatedRectByTwoPoint = ( + point1: IPoint, + point2: IPoint, +): IRectWithRotation => { + const { x, y } = point1; + const width = point2.x - point1.x; + const height = point2.y - point1.y; + const rotation = normalizeRadian(Math.atan2(height, width)); + const cx = x + width / 2; + const cy = y + height / 2; + const p = transformRotate(x, y, -rotation, cx, cy); + + return { + x: p.x, + y: p.y, + width: Math.sqrt(width * width + height * height), + height: 0, + rotation, + }; +}; diff --git a/packages/icons/src/icons/index.ts b/packages/icons/src/icons/index.ts index cb63a54a..738cb3d9 100644 --- a/packages/icons/src/icons/index.ts +++ b/packages/icons/src/icons/index.ts @@ -7,6 +7,7 @@ export * from './hide-outlined'; export * from './i18n-outlined'; export * from './line-width-outlined'; export * from './lock-filled'; +export * from './pen-outlined'; export * from './remove-outlined'; export * from './right-outlined'; export * from './show-outlined'; diff --git a/packages/icons/src/icons/pen-outlined.tsx b/packages/icons/src/icons/pen-outlined.tsx new file mode 100644 index 00000000..ea02b0dc --- /dev/null +++ b/packages/icons/src/icons/pen-outlined.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +export const PenOutlined = React.memo(() => { + return ( + + + + + + + ); +}); diff --git a/packages/suika/src/components/Cards/FillCard/FillCard.tsx b/packages/suika/src/components/Cards/FillCard/FillCard.tsx index a109f801..2298d6db 100644 --- a/packages/suika/src/components/Cards/FillCard/FillCard.tsx +++ b/packages/suika/src/components/Cards/FillCard/FillCard.tsx @@ -1,5 +1,5 @@ import { cloneDeep, isEqual } from '@suika/common'; -import { Graph, ITexture, SetElementsAttrs } from '@suika/core'; +import { Graph, ITexture, SetGraphsAttrsCmd } from '@suika/core'; import { FC, useContext, useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; @@ -72,7 +72,7 @@ export const FillCard: FC = () => { if (!editor) return; editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( cmdDesc, selectedElements, { fill: newStroke }, diff --git a/packages/suika/src/components/Cards/StrokeCard/StrokeCard.tsx b/packages/suika/src/components/Cards/StrokeCard/StrokeCard.tsx index 4955abc4..74b1ae82 100644 --- a/packages/suika/src/components/Cards/StrokeCard/StrokeCard.tsx +++ b/packages/suika/src/components/Cards/StrokeCard/StrokeCard.tsx @@ -3,7 +3,7 @@ import { Graph, ISetElementsAttrsType, ITexture, - SetElementsAttrs, + SetGraphsAttrsCmd, } from '@suika/core'; import { LineWidthOutlined } from '@suika/icons'; import { FC, useContext, useEffect, useRef, useState } from 'react'; @@ -157,7 +157,7 @@ export const StrokeCard: FC = () => { } editor.commandManager.pushCommand( - new SetElementsAttrs(cmdDesc, selectedElements, attrs, prevAttrs), + new SetGraphsAttrsCmd(cmdDesc, selectedElements, attrs, prevAttrs), ); prevStrokes.current = selectedElements.map((el) => cloneDeep(el.fill)); @@ -168,7 +168,7 @@ export const StrokeCard: FC = () => { const selectedElements = editor.selectedElements.getItems(); editor.commandManager.pushCommand( - new SetElementsAttrs( + new SetGraphsAttrsCmd( 'update strokeWidth', selectedElements, { strokeWidth: newStrokeWidth }, diff --git a/packages/suika/src/components/Header/components/Toolbar/Toolbar.tsx b/packages/suika/src/components/Header/components/Toolbar/Toolbar.tsx index ce478c74..c17eb294 100644 --- a/packages/suika/src/components/Header/components/Toolbar/Toolbar.tsx +++ b/packages/suika/src/components/Header/components/Toolbar/Toolbar.tsx @@ -4,6 +4,7 @@ import { EllipseOutlined, HandOutlined, LineOutlined, + PenOutlined, RectOutlined, SelectOutlined, TextFilled, @@ -53,6 +54,12 @@ export const ToolBar = () => { intlId: 'tool.ellipse', icon: , }, + { + name: 'drawPath', + hotkey: 'P', + intlId: 'tool.pen', + icon: , + }, { name: 'drawLine', hotkey: 'L', @@ -78,7 +85,7 @@ export const ToolBar = () => { className={classNames({ active: currTool === tool.name })} tooltipContent={intl.formatMessage({ id: tool.intlId })} hotkey={tool.hotkey} - onClick={() => { + onMouseDown={() => { editor?.toolManager.setActiveTool(tool.name); }} > diff --git a/packages/suika/src/components/Header/components/Toolbar/components/ToolBtn/ToolBtn.tsx b/packages/suika/src/components/Header/components/Toolbar/components/ToolBtn/ToolBtn.tsx index b2bbf974..f9f8e150 100644 --- a/packages/suika/src/components/Header/components/Toolbar/components/ToolBtn/ToolBtn.tsx +++ b/packages/suika/src/components/Header/components/Toolbar/components/ToolBtn/ToolBtn.tsx @@ -8,12 +8,12 @@ interface IToolBtn { children?: React.ReactNode; tooltipContent: string; hotkey: string; - onClick: () => void; + onMouseDown: () => void; } export const ToolBtn: FC = ({ children, - onClick, + onMouseDown, className, tooltipContent, hotkey, @@ -21,8 +21,8 @@ export const ToolBtn: FC = ({ return (
{ - onClick(); + onMouseDown={() => { + onMouseDown(); }} > {children} diff --git a/packages/suika/src/locale/en.json b/packages/suika/src/locale/en.json index fcf16489..9ec25dac 100644 --- a/packages/suika/src/locale/en.json +++ b/packages/suika/src/locale/en.json @@ -10,6 +10,7 @@ "tool.select": "Select", "tool.rectangle": "Rectangle", "tool.ellipse": "Ellipse", + "tool.pen": "Pen", "tool.line": "Line", "tool.text": "Text", "tool.hand": "Hand", diff --git a/packages/suika/src/locale/zh.json b/packages/suika/src/locale/zh.json index 2091fe79..9464bfd2 100644 --- a/packages/suika/src/locale/zh.json +++ b/packages/suika/src/locale/zh.json @@ -10,6 +10,7 @@ "tool.select": "选择", "tool.rectangle": "矩形", "tool.ellipse": "圆", + "tool.pen": "钢笔", "tool.line": "直线", "tool.text": "文字", "tool.hand": "拖拽画布", diff --git a/scripts/dev.js b/scripts/dev.js index c1a66a3d..57dd53db 100644 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -46,6 +46,9 @@ const setup = async () => { typecheckPlugin({ watch: true, omitStartLog: true, + // compilerOptions: { + // noUnusedLocals: false, + // }, logger: { info(message) { // don't log info @@ -70,7 +73,7 @@ const setup = async () => { setup(build) { build.onEnd((result) => { if (result.errors.length) { - console.error('build failed:', result.errors); + console.error('build failed...' /*, result.errors */); return; } console.log('watch build succeeded:', relativeOutputFile);