diff --git a/src/assets/textures/rect.js b/src/assets/textures/rect.js index 72827e01..b5efc0a1 100644 --- a/src/assets/textures/rect.js +++ b/src/assets/textures/rect.js @@ -2,17 +2,17 @@ import { Graphics } from 'pixi.js'; import { getColor } from '../../utils/get'; import { cacheKey, generateTexture } from './utils'; -export const createRectTexture = (rectOpts) => { +export const createRectTexture = (renderer, theme, rectOpts) => { const { fill = null, borderWidth = null, borderColor = null, radius = null, } = rectOpts; - const rect = createRect({ fill, borderWidth, borderColor, radius }); - const texture = generateTexture(rect); + const rect = createRect(theme, { fill, borderWidth, borderColor, radius }); + const texture = generateTexture(rect, renderer); - texture.id = cacheKey(rectOpts); + texture.id = cacheKey(renderer, rectOpts); texture.metadata = { slice: { topHeight: borderWidth + 4, @@ -26,7 +26,7 @@ export const createRectTexture = (rectOpts) => { return texture; }; -const createRect = ({ fill, borderWidth, borderColor, radius }) => { +const createRect = (theme, { fill, borderWidth, borderColor, radius }) => { const graphics = new Graphics(); const size = 20 + borderWidth; @@ -37,11 +37,11 @@ const createRect = ({ fill, borderWidth, borderColor, radius }) => { graphics.rect(...xywh); } - if (fill) graphics.fill(getColor(fill)); + if (fill) graphics.fill(getColor(theme, fill)); if (borderWidth) { graphics.stroke({ width: borderWidth, - color: getColor(borderColor), + color: getColor(theme, borderColor), }); } return graphics; diff --git a/src/assets/textures/texture.js b/src/assets/textures/texture.js index cf506ba2..1e56de75 100644 --- a/src/assets/textures/texture.js +++ b/src/assets/textures/texture.js @@ -1,26 +1,26 @@ -import { Assets, Cache } from 'pixi.js'; +import { Assets } from 'pixi.js'; import { createRectTexture } from './rect'; import { cacheKey } from './utils'; -export const getTexture = (config) => { +export const getTexture = (renderer, theme, config) => { let texture = null; if (typeof config === 'string') { texture = Assets.get(config); } else { - texture = Cache.has(cacheKey(config)) - ? Assets.get(cacheKey(config)) - : createTexture(config); + texture = Assets.cache.has(cacheKey(renderer, config)) + ? Assets.cache.get(cacheKey(renderer, config)) + : createTexture(renderer, theme, config); } return texture; }; -export const createTexture = (config) => { +export const createTexture = (renderer, theme, config) => { let texture = null; switch (config.type) { case 'rect': - texture = createRectTexture(config); + texture = createRectTexture(renderer, theme, config); break; } - Cache.set(cacheKey(config), texture); + Assets.cache.set(cacheKey(renderer, config), texture); return texture; }; diff --git a/src/assets/textures/utils.js b/src/assets/textures/utils.js index f660fc44..7f66cb8d 100644 --- a/src/assets/textures/utils.js +++ b/src/assets/textures/utils.js @@ -1,22 +1,22 @@ import { TextureStyle } from '../../display/data-schema/component-schema'; import { deepMerge } from '../../utils/deepmerge/deepmerge'; -import { renderer } from '../../utils/renderer'; const RESOLUTION = 5; -export const generateTexture = (target = null, opts = {}) => { +export const generateTexture = (target, renderer, opts = {}) => { const options = deepMerge({ resolution: RESOLUTION }, opts); if (!target) return; - const texture = renderer.get().generateTexture({ + const texture = renderer.generateTexture({ target, resolution: options.resolution, }); return texture; }; -export const cacheKey = (config) => { - return TextureStyle.keyof() - .options.map((key) => config[key]) - .join('-'); +export const cacheKey = (renderer, config) => { + return [ + renderer.uid, + ...TextureStyle.keyof().options.map((key) => config[key]), + ].join('-'); }; diff --git a/src/command/commands/tint.js b/src/command/commands/tint.js index 427944d0..14beb7dd 100644 --- a/src/command/commands/tint.js +++ b/src/command/commands/tint.js @@ -17,12 +17,14 @@ export class TintCommand extends Command { * Creates an instance of TintCommand. * @param {Object} object - The Pixi.js display object whose tint will be changed. * @param {Object} config - The new configuration for the object's tint. + * @param {object} options - Options for command execution. */ - constructor(object, config) { + constructor(object, config, options) { super('tint_object'); this.object = object; this._config = parsePick(config, optionKeys); this._prevConfig = parsePick(object.config, optionKeys); + this._options = options; } get config() { @@ -33,17 +35,21 @@ export class TintCommand extends Command { return this._prevConfig; } + get options() { + return this._options; + } + /** * Executes the command to change the object's tint. */ execute() { - changeTint(this.object, this.config); + changeTint(this.object, this.config, this.options); } /** * Undoes the command, reverting the object's tint to its previous state. */ undo() { - changeTint(this.object, this.prevConfig); + changeTint(this.object, this.prevConfig, this.options); } } diff --git a/src/command/index.js b/src/command/index.js index f07b568e..53afc78d 100644 --- a/src/command/index.js +++ b/src/command/index.js @@ -1,5 +1,3 @@ import * as commands from './commands'; -import { UndoRedoManager } from './undo-redo-manager'; export const Commands = commands; -export const undoRedoManager = new UndoRedoManager(); diff --git a/src/command/undo-redo-manager.js b/src/command/undo-redo-manager.js index 0425090c..50f12a22 100644 --- a/src/command/undo-redo-manager.js +++ b/src/command/undo-redo-manager.js @@ -7,6 +7,7 @@ export class UndoRedoManager { this._index = -1; this._listeners = new Set(); this._maxCommands = maxCommands; + this._hotkeyListener = null; } /** @@ -133,26 +134,36 @@ export class UndoRedoManager { * @private */ _setHotkeys() { - document.addEventListener( - 'keydown', - (e) => { - const key = (e.key || '').toLowerCase(); - if (isInput(e.target)) return; - - if (key === 'z' && (e.ctrlKey || e.metaKey)) { - if (e.shiftKey) { - this.redo(); - } else { - this.undo(); - } - e.preventDefault(); - } - if (key === 'y' && (e.ctrlKey || e.metaKey)) { + this._hotkeyListener = (e) => { + const key = (e.key || '').toLowerCase(); + if (isInput(e.target)) return; + + if (key === 'z' && (e.ctrlKey || e.metaKey)) { + if (e.shiftKey) { this.redo(); - e.preventDefault(); + } else { + this.undo(); } - }, - false, - ); + e.preventDefault(); + } + if (key === 'y' && (e.ctrlKey || e.metaKey)) { + this.redo(); + e.preventDefault(); + } + }; + + document.addEventListener('keydown', this._hotkeyListener, false); + } + + /** + * Removes event listeners and clears all internal states to prevent memory leaks. + */ + destroy() { + if (this._hotkeyListener) { + document.removeEventListener('keydown', this._hotkeyListener, false); + this._hotkeyListener = null; + } + this.clear(); + this._listeners.clear(); } } diff --git a/src/display/change/asset.js b/src/display/change/asset.js index 9fac2b69..3472a384 100644 --- a/src/display/change/asset.js +++ b/src/display/change/asset.js @@ -1,12 +1,14 @@ import { getTexture } from '../../assets/textures/texture'; +import { getViewport } from '../../utils/get'; import { isConfigMatch, updateConfig } from './utils'; -export const changeAsset = (object, { asset: assetConfig }) => { +export const changeAsset = (object, { asset: assetConfig }, { theme }) => { if (isConfigMatch(object, 'asset', assetConfig)) { return; } - const asset = getTexture(assetConfig); + const renderer = getViewport(object).app.renderer; + const asset = getTexture(renderer, theme, assetConfig); if (!asset) { console.warn(`Asset not found for config: ${JSON.stringify(assetConfig)}`); } diff --git a/src/display/change/percent-size.js b/src/display/change/percent-size.js index e3a823af..3a6e401e 100644 --- a/src/display/change/percent-size.js +++ b/src/display/change/percent-size.js @@ -11,6 +11,7 @@ export const changePercentSize = ( margin = object.config.margin, animationDuration = object.config.animationDuration, }, + { animationContext }, ) => { if ( isConfigMatch(object, 'percentWidth', percentWidth) && @@ -21,8 +22,12 @@ export const changePercentSize = ( } const marginObj = parseMargin(margin); - if (percentWidth) changeWidth(object, percentWidth, marginObj); - if (percentHeight) changeHeight(object, percentHeight, marginObj); + if (Number.isFinite(percentWidth)) { + changeWidth(object, percentWidth, marginObj); + } + if (Number.isFinite(percentHeight)) { + changeHeight(object, percentHeight, marginObj); + } updateConfig(object, { percentWidth, percentHeight, @@ -41,12 +46,14 @@ export const changePercentSize = ( component.parent.size.height - (marginObj.top + marginObj.bottom); if (object.config.animation) { - killTweensOf(component); - gsap.to(component, { - pixi: { height: maxHeight * percentHeight }, - duration: animationDuration / 1000, - ease: 'power2.inOut', - onUpdate: () => changePlacement(component, {}), + animationContext.add(() => { + killTweensOf(component); + gsap.to(component, { + pixi: { height: maxHeight * percentHeight }, + duration: animationDuration / 1000, + ease: 'power2.inOut', + onUpdate: () => changePlacement(component, {}), + }); }); } else { component.height = maxHeight * percentHeight; diff --git a/src/display/change/pipeline/base.js b/src/display/change/pipeline/base.js index 7b08d630..06212bda 100644 --- a/src/display/change/pipeline/base.js +++ b/src/display/change/pipeline/base.js @@ -2,7 +2,7 @@ import * as change from '..'; import { Commands } from '../../../command'; import { createCommandHandler } from './utils'; -export const pipeline = { +export const basePipeline = { show: { keys: ['show'], handler: createCommandHandler(Commands.ShowCommand, change.changeShow), diff --git a/src/display/change/pipeline/component.js b/src/display/change/pipeline/component.js index 1bc21f44..f9d86159 100644 --- a/src/display/change/pipeline/component.js +++ b/src/display/change/pipeline/component.js @@ -1,24 +1,24 @@ import * as change from '..'; import { Commands } from '../../../command'; -import { pipeline } from './base'; +import { basePipeline } from './base'; import { createCommandHandler } from './utils'; export const componentPipeline = { - ...pipeline, + ...basePipeline, tint: { keys: ['color', 'tint'], handler: createCommandHandler(Commands.TintCommand, change.changeTint), }, texture: { keys: ['texture'], - handler: (component, config) => { - change.changeTexture(component, config); + handler: (component, config, options) => { + change.changeTexture(component, config, options); }, }, asset: { keys: ['asset'], - handler: (component, config) => { - change.changeAsset(component, config); + handler: (component, config, options) => { + change.changeAsset(component, config, options); }, }, textureTransform: { @@ -35,8 +35,8 @@ export const componentPipeline = { }, percentSize: { keys: ['percentWidth', 'percentHeight', 'margin'], - handler: (component, config) => { - change.changePercentSize(component, config); + handler: (component, config, options) => { + change.changePercentSize(component, config, options); change.changePlacement(component, {}); }, }, @@ -60,8 +60,8 @@ export const componentPipeline = { }, textStyle: { keys: ['style', 'margin'], - handler: (component, config) => { - change.changeTextStyle(component, config); + handler: (component, config, options) => { + change.changeTextStyle(component, config, options); change.changePlacement(component, config); // Ensure placement is updated after style change }, }, diff --git a/src/display/change/pipeline/element.js b/src/display/change/pipeline/element.js index 2d495a9e..41a0954d 100644 --- a/src/display/change/pipeline/element.js +++ b/src/display/change/pipeline/element.js @@ -1,11 +1,11 @@ import * as change from '..'; import { Commands } from '../../../command'; import { updateComponents } from '../../update/update-components'; -import { pipeline } from './base'; +import { basePipeline } from './base'; import { createCommandHandler } from './utils'; export const elementPipeline = { - ...pipeline, + ...basePipeline, position: { keys: ['position'], handler: createCommandHandler( diff --git a/src/display/change/pipeline/utils.js b/src/display/change/pipeline/utils.js index aced9917..6a860a03 100644 --- a/src/display/change/pipeline/utils.js +++ b/src/display/change/pipeline/utils.js @@ -1,13 +1,12 @@ -import { undoRedoManager } from '../../../command'; - export const createCommandHandler = (Command, changeFn) => { return (object, config, options) => { + const { undoRedoManager } = options; if (options?.historyId) { - undoRedoManager.execute(new Command(object, config), { + undoRedoManager.execute(new Command(object, config, options), { historyId: options.historyId, }); } else { - changeFn(object, config); + changeFn(object, config, options); } }; }; diff --git a/src/display/change/stroke-style.js b/src/display/change/stroke-style.js index 6a5d8df5..e3e72627 100644 --- a/src/display/change/stroke-style.js +++ b/src/display/change/stroke-style.js @@ -2,12 +2,16 @@ import { getColor } from '../../utils/get'; import { selector } from '../../utils/selector/selector'; import { updateConfig } from './utils'; -export const changeStrokeStyle = (object, { strokeStyle, links }) => { +export const changeStrokeStyle = ( + object, + { strokeStyle, links }, + { theme }, +) => { const path = selector(object, '$.children[?(@.type==="path")]')[0]; if (!path) return; if ('color' in strokeStyle) { - strokeStyle.color = getColor(strokeStyle.color); + strokeStyle.color = getColor(theme, strokeStyle.color); } path.setStrokeStyle({ ...path.strokeStyle, ...strokeStyle }); diff --git a/src/display/change/text-style.js b/src/display/change/text-style.js index 6498d8bb..f262d63a 100644 --- a/src/display/change/text-style.js +++ b/src/display/change/text-style.js @@ -6,6 +6,7 @@ import { isConfigMatch, updateConfig } from './utils'; export const changeTextStyle = ( object, { style = object.config.style, margin = object.config.margin }, + { theme }, ) => { if ( isConfigMatch(object, 'style', style) && @@ -18,7 +19,7 @@ export const changeTextStyle = ( if (key === 'fontFamily' || key === 'fontWeight') { object.style.fontFamily = `${style.fontFamily ?? object.style.fontFamily.split(' ')[0]} ${FONT_WEIGHT[style.fontWeight ?? object.style.fontWeight]}`; } else if (key === 'fill') { - object.style[key] = getColor(style.fill); + object.style[key] = getColor(theme, style.fill); } else if (key === 'fontSize' && style[key] === 'auto') { const marginObj = parseMargin(margin); setAutoFontSize(object, marginObj); diff --git a/src/display/change/texture.js b/src/display/change/texture.js index 8ce99627..485a57c4 100644 --- a/src/display/change/texture.js +++ b/src/display/change/texture.js @@ -1,13 +1,21 @@ import { getTexture } from '../../assets/textures/texture'; import { deepMerge } from '../../utils/deepmerge/deepmerge'; +import { getViewport } from '../../utils/get'; import { isConfigMatch, updateConfig } from './utils'; -export const changeTexture = (object, { texture: textureConfig }) => { +export const changeTexture = ( + object, + { texture: textureConfig }, + { theme }, +) => { if (isConfigMatch(object, 'texture', textureConfig)) { return; } + const renderer = getViewport(object).app.renderer; const texture = getTexture( + renderer, + theme, deepMerge(object.texture?.metadata?.config, textureConfig), ); object.texture = texture ?? null; diff --git a/src/display/change/tint.js b/src/display/change/tint.js index fbd00206..1cba08b4 100644 --- a/src/display/change/tint.js +++ b/src/display/change/tint.js @@ -1,8 +1,8 @@ import { getColor } from '../../utils/get'; import { updateConfig } from './utils'; -export const changeTint = (object, { color, tint }) => { - const hexColor = getColor(tint ?? color); +export const changeTint = (object, { color, tint }, { theme }) => { + const hexColor = getColor(theme, tint ?? color); object.tint = hexColor; updateConfig(object, { tint }); }; diff --git a/src/display/draw.js b/src/display/draw.js index 43713cdd..599a58c5 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -1,58 +1,32 @@ -import gsap from 'gsap'; import { createGrid } from './elements/grid'; import { createGroup } from './elements/group'; import { createItem } from './elements/item'; import { createRelations } from './elements/relations'; import { update } from './update/update'; -export const draw = (viewport, data) => { - gsap.globalTimeline.clear(); +const elementcreators = { + group: createGroup, + grid: createGrid, + item: createItem, + relations: createRelations, +}; + +export const draw = (context, data) => { + const { viewport } = context; destroyChildren(viewport); render(viewport, data); function render(parent, data) { for (const config of data) { - switch (config.type) { - case 'group': { - const element = createGroup(config); - element.viewport = viewport; - update(null, { - elements: element, - changes: config, - }); - parent.addChild(element); + const creator = elementcreators[config.type]; + if (creator) { + const element = creator(config); + element.viewport = viewport; + update(context, { elements: element, changes: config }); + parent.addChild(element); + + if (config.type === 'group') { render(element, config.items); - break; - } - case 'grid': { - const element = createGrid(config); - element.viewport = viewport; - update(null, { - elements: element, - changes: config, - }); - parent.addChild(element); - break; - } - case 'item': { - const element = createItem(config); - element.viewport = viewport; - update(null, { - elements: element, - changes: config, - }); - parent.addChild(element); - break; - } - case 'relations': { - const element = createRelations({ viewport, ...config }); - element.viewport = viewport; - update(null, { - elements: element, - changes: config, - }); - parent.addChild(element); - break; } } } diff --git a/src/display/elements/grid.js b/src/display/elements/grid.js index 03622887..b9a0bcab 100644 --- a/src/display/elements/grid.js +++ b/src/display/elements/grid.js @@ -24,10 +24,10 @@ export const createGrid = (config) => { }; const pipelineKeys = ['show', 'position', 'gridComponents']; -export const updateGrid = (element, config, options) => { - const validateConfig = validate(config, deepGridObject); - if (isValidationError(validateConfig)) throw validateConfig; - updateObject(element, config, elementPipeline, pipelineKeys, options); +export const updateGrid = (element, changes, options) => { + const validated = validate(changes, deepGridObject); + if (isValidationError(validated)) throw validated; + updateObject(element, changes, elementPipeline, pipelineKeys, options); }; const addItemElements = (container, cells, cellSize) => { diff --git a/src/display/elements/group.js b/src/display/elements/group.js index 7f579183..bcb772db 100644 --- a/src/display/elements/group.js +++ b/src/display/elements/group.js @@ -11,8 +11,8 @@ export const createGroup = (config) => { }; const pipelineKeys = ['show', 'position']; -export const updateGroup = (element, config, options) => { - const validateConfig = validate(config, deepGroupObject); - if (isValidationError(validateConfig)) throw validateConfig; - updateObject(element, config, elementPipeline, pipelineKeys, options); +export const updateGroup = (element, changes, options) => { + const validated = validate(changes, deepGroupObject); + if (isValidationError(validated)) throw validated; + updateObject(element, changes, elementPipeline, pipelineKeys, options); }; diff --git a/src/display/elements/item.js b/src/display/elements/item.js index 4c68671f..75d8bbd1 100644 --- a/src/display/elements/item.js +++ b/src/display/elements/item.js @@ -18,8 +18,8 @@ export const createItem = (config) => { }; const pipelineKeys = ['show', 'position', 'components']; -export const updateItem = (element, config, options) => { - const validateConfig = validate(config, deepSingleObject); - if (isValidationError(validateConfig)) throw validateConfig; - updateObject(element, config, elementPipeline, pipelineKeys, options); +export const updateItem = (element, changes, options) => { + const validated = validate(changes, deepSingleObject); + if (isValidationError(validated)) throw validated; + updateObject(element, changes, elementPipeline, pipelineKeys, options); }; diff --git a/src/display/elements/relations.js b/src/display/elements/relations.js index c563bbc1..5023af84 100644 --- a/src/display/elements/relations.js +++ b/src/display/elements/relations.js @@ -14,10 +14,10 @@ export const createRelations = (config) => { }; const pipelineKeys = ['show', 'strokeStyle', 'links']; -export const updateRelations = (element, config, options) => { - const validateConfig = validate(config, deepRelationGroupObject); - if (isValidationError(validateConfig)) throw validateConfig; - updateObject(element, config, elementPipeline, pipelineKeys, options); +export const updateRelations = (element, changes, options) => { + const validated = validate(changes, deepRelationGroupObject); + if (isValidationError(validated)) throw validated; + updateObject(element, changes, elementPipeline, pipelineKeys, options); }; const createPath = () => { diff --git a/src/display/update/update-components.js b/src/display/update/update-components.js index 3dac1c87..7c7f0fce 100644 --- a/src/display/update/update-components.js +++ b/src/display/update/update-components.js @@ -53,7 +53,7 @@ export const updateComponents = ( item.addChild(component); } - componentFn[component.type].update(component, { ...config }, options); + componentFn[component.type].update(component, config, options); } }; diff --git a/src/display/update/update-object.js b/src/display/update/update-object.js index 5be85a30..40f3470a 100644 --- a/src/display/update/update-object.js +++ b/src/display/update/update-object.js @@ -4,7 +4,7 @@ const DEFAULT_EXCEPTION_KEYS = new Set(['position']); export const updateObject = ( object, - config, + changes, pipeline, pipelineKeys, options, @@ -13,14 +13,14 @@ export const updateObject = ( const pipelines = pipelineKeys.map((key) => pipeline[key]).filter(Boolean); for (const { keys, handler } of pipelines) { - const hasMatch = keys.some((key) => key in config); + const hasMatch = keys.some((key) => key in changes); if (hasMatch) { - handler(object, config, options); + handler(object, changes, options); } } const matchedKeys = new Set(pipelines.flatMap((item) => item.keys)); - for (const [key, value] of Object.entries(config)) { + for (const [key, value] of Object.entries(changes)) { if (!matchedKeys.has(key) && !DEFAULT_EXCEPTION_KEYS.has(key)) { changeProperty(object, key, value); } diff --git a/src/display/update/update.js b/src/display/update/update.js index 40fb3b2c..c8e93f85 100644 --- a/src/display/update/update.js +++ b/src/display/update/update.js @@ -16,14 +16,22 @@ const updateSchema = z.object({ relativeTransform: z.boolean().default(false), }); -export const update = (parent, opts) => { +const elementUpdaters = { + group: updateGroup, + grid: updateGrid, + item: updateItem, + relations: updateRelations, +}; + +export const update = (context, opts) => { const config = validate(opts, updateSchema.passthrough()); if (isValidationError(config)) throw config; + const { viewport = null, ...otherContext } = context; const historyId = createHistoryId(config.saveToHistory); const elements = 'elements' in config ? convertArray(config.elements) : []; - if (parent && config.path) { - elements.push(...selector(parent, config.path)); + if (viewport && config.path) { + elements.push(...selector(viewport, config.path)); } for (const element of elements) { @@ -33,19 +41,9 @@ export const update = (parent, opts) => { elConfig.changes = applyRelativeTransform(element, elConfig.changes); } - switch (element.type) { - case 'group': - updateGroup(element, elConfig.changes, { historyId }); - break; - case 'grid': - updateGrid(element, elConfig.changes, { historyId }); - break; - case 'item': - updateItem(element, elConfig.changes, { historyId }); - break; - case 'relations': - updateRelations(element, elConfig.changes, { historyId }); - break; + const updater = elementUpdaters[element.type]; + if (updater) { + updater(element, elConfig.changes, { historyId, ...otherContext }); } } }; diff --git a/src/events/drag-select.js b/src/events/drag-select.js index 916a86de..912d068a 100644 --- a/src/events/drag-select.js +++ b/src/events/drag-select.js @@ -1,4 +1,3 @@ -import { Graphics } from 'pixi.js'; import { isValidationError } from 'zod-validation-error'; import { getPointerPosition } from '../utils/canvas'; import { deepMerge } from '../utils/deepmerge/deepmerge'; @@ -11,33 +10,27 @@ import { checkEvents, isMoved } from './utils'; const DRAG_SELECT_EVENT_ID = 'drag-select-down drag-select-move drag-select-up'; const DEBOUNCE_FN_INTERVAL = 25; // ms -let config = {}; -let lastMoveTime = 0; -const state = { - isDragging: false, - startPoint: null, - endPoint: null, - movePoint: null, - box: new Graphics(), -}; - -export const dragSelect = (viewport, opts) => { - const options = validate(deepMerge(config, opts), dragSelectEventSchema); +export const dragSelect = (viewport, state, opts) => { + const options = validate( + deepMerge(state.config, opts), + dragSelectEventSchema, + ); if (isValidationError(options)) throw options; if (!checkEvents(viewport, DRAG_SELECT_EVENT_ID)) { - addEvents(viewport); + addEvents(viewport, state); } changeDraggableState( viewport, - config.enabled && config.draggable, + state, + state.config.enabled && state.config.draggable, options.enabled && options.draggable, ); - config = options; + state.config = options; }; -const addEvents = (viewport) => { +const addEvents = (viewport, state) => { event.removeEvent(viewport, DRAG_SELECT_EVENT_ID); registerDownEvent(); registerMoveEvent(); @@ -48,13 +41,13 @@ const addEvents = (viewport) => { id: 'drag-select-down', action: 'mousedown touchstart', fn: () => { - resetState(); + resetState(state); const point = getPointerPosition(viewport); state.isDragging = true; state.box.renderable = true; - state.startPoint = { ...point }; - state.movePoint = { ...point }; + state.point.start = { ...point }; + state.point.move = { ...point }; }, }); } @@ -66,13 +59,13 @@ const addEvents = (viewport) => { fn: (e) => { if (!state.isDragging) return; - state.endPoint = { ...getPointerPosition(viewport) }; - drawSelectionBox(); + state.point.end = { ...getPointerPosition(viewport) }; + drawSelectionBox(state); - if (isMoved(viewport, state.movePoint, state.endPoint)) { + if (isMoved(viewport, state.point.move, state.point.end)) { viewport.plugin.start('mouse-edges'); - triggerFn(viewport, e); - state.movePoint = JSON.parse(JSON.stringify(state.endPoint)); + triggerFn(viewport, e, state); + state.point.move = JSON.parse(JSON.stringify(state.point.end)); } }, }); @@ -84,56 +77,59 @@ const addEvents = (viewport) => { action: 'mouseup touchend mouseleave', fn: (e) => { if ( - state.startPoint && - state.endPoint && - isMoved(viewport, state.startPoint, state.endPoint) + state.point.start && + state.point.end && + isMoved(viewport, state.point.start, state.point.end) ) { - triggerFn(viewport, e); + triggerFn(viewport, e, state); viewport.plugin.stop('mouse-edges'); } - resetState(); + resetState(state); }, }); } }; -const drawSelectionBox = () => { - const { box, startPoint, endPoint } = state; - if (!startPoint || !endPoint) return; +const drawSelectionBox = (state) => { + const { box, point } = state; + if (!point.start || !point.end) return; box.clear(); box.position.set( - Math.min(startPoint.x, endPoint.x), - Math.min(startPoint.y, endPoint.y), + Math.min(point.start.x, point.end.x), + Math.min(point.start.y, point.end.y), ); box .rect( 0, 0, - Math.abs(startPoint.x - endPoint.x), - Math.abs(startPoint.y - endPoint.y), + Math.abs(point.start.x - point.end.x), + Math.abs(point.start.y - point.end.y), ) .fill({ color: '#9FD6FF', alpha: 0.2 }) .stroke({ width: 2, color: '#1099FF', pixelLine: true }); }; -const triggerFn = (viewport, e) => { +const triggerFn = (viewport, e, state) => { const now = performance.now(); - if (e.type === 'pointermove' && now - lastMoveTime < DEBOUNCE_FN_INTERVAL) { + if ( + e.type === 'pointermove' && + now - state.lastMoveTime < DEBOUNCE_FN_INTERVAL + ) { return; } - lastMoveTime = now; + state.lastMoveTime = now; const intersectObjs = - state.startPoint && state.endPoint - ? findIntersectObjects(viewport, state, config) + state.point.start && state.point.end + ? findIntersectObjects(viewport, state, state.config) : []; - if ('onDragSelect' in config) { - config.onDragSelect(intersectObjs, e); + if ('onDragSelect' in state.config) { + state.config.onDragSelect(intersectObjs, e); } }; -const changeDraggableState = (viewport, wasDraggable, isDraggable) => { +const changeDraggableState = (viewport, state, wasDraggable, isDraggable) => { if (wasDraggable === isDraggable) return; if (isDraggable) { @@ -142,31 +138,29 @@ const changeDraggableState = (viewport, wasDraggable, isDraggable) => { }); viewport.plugin.stop('mouse-edges'); event.onEvent(viewport, DRAG_SELECT_EVENT_ID); - addChildBox(viewport); + addChildBox(viewport, state); } else { viewport.plugin.remove('mouse-edges'); event.offEvent(viewport, DRAG_SELECT_EVENT_ID); - resetState(); - removeChildBox(viewport); + resetState(state); + removeChildBox(viewport, state); } }; -const resetState = () => { +const resetState = (state) => { state.isDragging = false; - state.startPoint = null; - state.endPoint = null; - state.movePoint = null; + state.point = { start: null, end: null, move: null }; state.box.clear(); state.box.renderable = false; }; -const addChildBox = (viewport) => { +const addChildBox = (viewport, state) => { if (!state.box.parent) { viewport.addChild(state.box); } }; -const removeChildBox = (viewport) => { +const removeChildBox = (viewport, state) => { if (state.box.parent) { viewport.removeChild(state.box); } diff --git a/src/events/single-select.js b/src/events/single-select.js index 3c758229..8871dca4 100644 --- a/src/events/single-select.js +++ b/src/events/single-select.js @@ -9,22 +9,19 @@ import { checkEvents, isMoved } from './utils'; const SELECT_EVENT_ID = 'select-down select-up select-over'; -let config = {}; -let state = { startPosition: null, endPosition: null }; - -export const select = (viewport, opts) => { - const options = validate(deepMerge(config, opts), selectEventSchema); +export const select = (viewport, state, opts) => { + const options = validate(deepMerge(state.config, opts), selectEventSchema); if (isValidationError(options)) throw options; if (!checkEvents(viewport, SELECT_EVENT_ID)) { - addEvents(viewport); + addEvents(viewport, state); } - changeEnableState(viewport, config.enabled, options.enabled); - config = options; + changeEnableState(viewport, state.config.enabled, options.enabled); + state.config = options; }; -const addEvents = (viewport) => { +const addEvents = (viewport, state) => { event.removeEvent(viewport, SELECT_EVENT_ID); registerDownEvent(); registerUpEvent(); @@ -35,7 +32,7 @@ const addEvents = (viewport) => { id: 'select-down', action: 'mousedown touchstart', fn: () => { - state.startPosition = { + state.position.start = { x: viewport.position.x, y: viewport.position.y, }; @@ -48,19 +45,19 @@ const addEvents = (viewport) => { id: 'select-up', action: 'mouseup touchend', fn: (e) => { - state.endPosition = { + state.position.end = { x: viewport.position.x, y: viewport.position.y, }; if ( - state.startPosition && - !isMoved(viewport, state.startPosition, state.endPosition) + state.position.start && + !isMoved(viewport, state.position.start, state.position.end) ) { executeFn('onSelect', e); } - state = { startPosition: null, endPosition: null }; + state.position = { start: null, end: null }; executeFn('onOver', e); }, }); @@ -78,8 +75,11 @@ const addEvents = (viewport) => { function executeFn(fnName, e) { const point = getPointerPosition(viewport); - if (fnName in config) { - config[fnName](findIntersectObject(viewport, { point }, config), e); + if (fnName in state.config) { + state.config[fnName]( + findIntersectObject(viewport, { point }, state.config), + e, + ); } } }; diff --git a/src/init.js b/src/init.js index 2451cf58..b5e82b9f 100644 --- a/src/init.js +++ b/src/init.js @@ -4,9 +4,9 @@ import { Viewport } from 'pixi-viewport'; import * as PIXI from 'pixi.js'; import { firaCode } from './assets/fonts'; import { icons } from './assets/icons'; -import { transformManifest } from './assets/utils'; import { deepMerge } from './utils/deepmerge/deepmerge'; import { plugin } from './utils/event/viewport'; +import { uid } from './utils/uuid'; gsap.registerPlugin(PixiPlugin); PixiPlugin.registerPIXI(PIXI); @@ -30,23 +30,30 @@ const DEFAULT_INIT_OPTIONS = { decelerate: {}, }, }, - assets: { - icons: { - object: { src: icons.object }, - inverter: { src: icons.inverter }, - combiner: { src: icons.combiner }, - edge: { src: icons.edge }, - device: { src: icons.device }, - loading: { src: icons.loading }, - warning: { src: icons.warning }, - wifi: { src: icons.wifi }, + assets: [ + { + name: 'icons', + items: Object.entries(icons).map(([alias, src]) => ({ + alias, + src, + data: { resolution: 3 }, + })), }, - }, + { + name: 'fonts', + items: Object.entries(firaCode).map(([key, font]) => ({ + alias: `firaCode-${key}`, + src: font, + data: { family: `FiraCode ${key}` }, + })), + }, + ], }; export const initApp = async (app, opts = {}) => { const options = deepMerge(DEFAULT_INIT_OPTIONS.app, opts); await app.init(options); + app.renderer.uid = uid(); }; export const initViewport = (app, opts = {}) => { @@ -60,6 +67,7 @@ export const initViewport = (app, opts = {}) => { opts, ); const viewport = new Viewport(options); + viewport.app = app; viewport.type = 'canvas'; viewport.events = {}; viewport.plugin = { @@ -74,16 +82,33 @@ export const initViewport = (app, opts = {}) => { }; export const initAsset = async (opts = {}) => { - const options = deepMerge(DEFAULT_INIT_OPTIONS.assets, opts); - const manifest = transformManifest(options); - await PIXI.Assets.init({ manifest }); - const fontBundle = Object.entries(firaCode).map(([key, font]) => ({ - alias: `firaCode-${key}`, - src: font, - data: { family: `FiraCode ${key}` }, - })); - PIXI.Assets.addBundle('fonts', fontBundle); - await PIXI.Assets.loadBundle([...Object.keys(options), 'fonts']); + const assets = deepMerge(DEFAULT_INIT_OPTIONS.assets, opts, { + mergeBy: ['name', 'alias'], + }); + + const bundlesToLoad = []; + const assetsToLoad = []; + for (const asset of assets) { + if (asset.name && Array.isArray(asset.items)) { + if (!PIXI.Assets.resolver.hasBundle(asset.name)) { + PIXI.Assets.addBundle(asset.name, asset.items); + bundlesToLoad.push(asset.name); + } + } else if (asset.alias && asset.src) { + if (!PIXI.Assets.cache.has(asset.alias)) { + PIXI.Assets.add(asset); + assetsToLoad.push(asset.alias); + } + } + } + await Promise.all([ + bundlesToLoad.length > 0 + ? PIXI.Assets.loadBundle(bundlesToLoad) + : Promise.resolve(), + assetsToLoad.length > 0 + ? PIXI.Assets.load(assetsToLoad) + : Promise.resolve(), + ]); }; export const initResizeObserver = (el, app, viewport) => { diff --git a/src/patch-map.ts b/src/patch-map.ts index 7a184528..74c3c9e6 100644 --- a/src/patch-map.ts +++ b/src/patch-map.ts @@ -1,4 +1,4 @@ export { Patchmap } from './patchmap'; -export { undoRedoManager } from './command'; +export { UndoRedoManager } from './command/undo-redo-manager'; export { Command } from './command/commands/base'; export * as change from './display/change'; diff --git a/src/patchmap.js b/src/patchmap.js index 406cf93a..ef65011b 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -1,7 +1,7 @@ import gsap from 'gsap'; -import { Application, Assets } from 'pixi.js'; +import { Application, Graphics } from 'pixi.js'; import { isValidationError } from 'zod-validation-error'; -import { undoRedoManager } from './command'; +import { UndoRedoManager } from './command/undo-redo-manager'; import { draw } from './display/draw'; import { update } from './display/update/update'; import { dragSelect } from './events/drag-select'; @@ -16,9 +16,8 @@ import { } from './init'; import { convertLegacyData } from './utils/convert'; import { event } from './utils/event/canvas'; -import { renderer } from './utils/renderer'; import { selector } from './utils/selector/selector'; -import { theme } from './utils/theme'; +import { themeStore } from './utils/theme'; import { validateMapData } from './utils/validator'; class Patchmap { @@ -27,6 +26,12 @@ class Patchmap { this._viewport = null; this._resizeObserver = null; this._isInit = false; + this._theme = themeStore(); + this._undoRedoManager = new UndoRedoManager(); + this._animationContext = gsap.context(() => {}); + + this._singleSelectState = null; + this._dragSelectState = null; } get app() { @@ -38,13 +43,21 @@ class Patchmap { } get theme() { - return theme.get(); + return this._theme.get(); } get isInit() { return this._isInit; } + get undoRedoManager() { + return this._undoRedoManager; + } + + get animationContext() { + return this._animationContext; + } + get event() { return { add: (opts) => { @@ -53,6 +66,7 @@ class Patchmap { return id; }, remove: (id) => event.removeEvent(this.viewport, id), + removeAll: () => event.removeAllEvent(this.viewport), on: (id) => event.onEvent(this.viewport, id), off: (id) => event.offEvent(this.viewport, id), get: (id) => event.getEvent(this.viewport, id), @@ -67,16 +81,15 @@ class Patchmap { app: appOptions = {}, viewport: viewportOptions = {}, theme: themeOptions = {}, - asset: assetOptions = {}, + assets: assetsOptions = [], } = opts; - undoRedoManager._setHotkeys(); - theme.set(themeOptions); + this.undoRedoManager._setHotkeys(); + this._theme.set(themeOptions); this._app = new Application(); await initApp(this.app, { resizeTo: element, ...appOptions }); this._viewport = initViewport(this.app, viewportOptions); - await initAsset(assetOptions); - renderer.set(this.app.renderer); + await initAsset(assetsOptions); initCanvas(element, this.app); this._resizeObserver = initResizeObserver(element, this.app, this.viewport); @@ -84,10 +97,11 @@ class Patchmap { } destroy() { - gsap.globalTimeline.clear(); - Assets.reset(); + this.undoRedoManager.destroy(); + this.animationContext.revert(); + event.removeAllEvent(this.viewport); + this.viewport.destroy({ children: true, context: true, style: true }); const parentElement = this.app.canvas.parentElement; - this.viewport.destroy(true); this.app.destroy(true); parentElement.remove(); if (this._resizeObserver) this._resizeObserver.disconnect(); @@ -96,6 +110,11 @@ class Patchmap { this._viewport = null; this._resizeObserver = null; this._isInit = false; + this._theme = themeStore(); + this._undoRedoManager = new UndoRedoManager(); + this._animationContext = gsap.context(() => {}); + this._singleSelectState = null; + this._dragSelectState = null; } draw(data) { @@ -106,9 +125,18 @@ class Patchmap { if (isValidationError(validatedData)) throw validatedData; this.app.stop(); - draw(this.viewport, validatedData); + this.undoRedoManager.clear(); + this.animationContext.revert(); + event.removeAllEvent(this.viewport); + this.initSelectState(); + const context = { + viewport: this.viewport, + undoRedoManager: this.undoRedoManager, + theme: this.theme, + animationContext: this.animationContext, + }; + draw(context, validatedData); this.app.start(); - undoRedoManager.clear(); return validatedData; function preprocessData(data) { @@ -131,7 +159,13 @@ class Patchmap { } update(opts) { - update(this.viewport, opts); + const context = { + viewport: this.viewport, + undoRedoManager: this.undoRedoManager, + theme: this.theme, + animationContext: this.animationContext, + }; + update(context, opts); } focus(ids) { @@ -147,8 +181,22 @@ class Patchmap { } select(opts) { - select(this.viewport, opts); - dragSelect(this.viewport, opts); + select(this.viewport, this._singleSelectState, opts); + dragSelect(this.viewport, this._dragSelectState, opts); + } + + initSelectState() { + this._singleSelectState = { + config: {}, + position: { start: null, end: null }, + }; + this._dragSelectState = { + config: {}, + lastMoveTime: 0, + isDragging: false, + point: { start: null, end: null, move: null }, + box: new Graphics(), + }; } } diff --git a/src/utils/canvas.js b/src/utils/canvas.js index 6ffff8e9..bb1a921c 100644 --- a/src/utils/canvas.js +++ b/src/utils/canvas.js @@ -1,5 +1,3 @@ -import { renderer } from './renderer'; - export const getScaleBounds = (viewport, object) => { const bounds = object.getBounds(); return { @@ -11,6 +9,7 @@ export const getScaleBounds = (viewport, object) => { }; export const getPointerPosition = (viewport) => { - const global = renderer.get().events.pointer.global; + const renderer = viewport?.app?.renderer; + const global = renderer?.events.pointer.global; return viewport ? viewport.toWorld(global.x, global.y) : global; }; diff --git a/src/utils/deepmerge/deepmerge.js b/src/utils/deepmerge/deepmerge.js index 4ebb6778..5019c40f 100644 --- a/src/utils/deepmerge/deepmerge.js +++ b/src/utils/deepmerge/deepmerge.js @@ -56,12 +56,13 @@ const _deepMerge = (target, source, options, visited) => { }; const mergeArray = (target, source, options, visited) => { + const { mergeBy } = options; const merged = [...target]; const used = new Set(); source.forEach((item, i) => { if (item && typeof item === 'object' && !Array.isArray(item)) { - const idx = findIndexByPriority(merged, item, used); + const idx = findIndexByPriority(merged, item, used, mergeBy); if (idx !== -1) { merged[idx] = _deepMerge(merged[idx], item, options, visited); used.add(idx); diff --git a/src/utils/event/canvas.js b/src/utils/event/canvas.js index 62e5be1a..d78193ec 100644 --- a/src/utils/event/canvas.js +++ b/src/utils/event/canvas.js @@ -41,6 +41,10 @@ export const removeEvent = (viewport, id) => { } }; +export const removeAllEvent = (viewport) => { + removeEvent(viewport, Object.keys(getAllEvent(viewport)).join(' ')); +}; + export const onEvent = (viewport, id) => { const eventIds = splitByWhitespace(id); if (!eventIds.length) return; @@ -101,6 +105,7 @@ export const getAllEvent = (viewport) => viewport.events; export const event = { addEvent, removeEvent, + removeAllEvent, onEvent, offEvent, getEvent, diff --git a/src/utils/findIndexByPriority.js b/src/utils/findIndexByPriority.js index 4b605766..02525e86 100644 --- a/src/utils/findIndexByPriority.js +++ b/src/utils/findIndexByPriority.js @@ -5,26 +5,32 @@ const schema = z.object({ criteria: z.object({}).passthrough(), }); -export const findIndexByPriority = (arr, criteria, usedIndexes = new Set()) => { +/** + * Finds the index of the first item in an array that matches the criteria, based on a priority list of keys. + * @param {Array} arr - The array to search. + * @param {Object} criteria - The criteria object to match. + * @param {Array} [priorityKeys=['id', 'label', 'type']] - The list of keys to check, in order of priority. + * @param {Set} [usedIndexes=new Set()] - A set of indices to exclude from the search. + * @returns {number} - The index of the first matching item, or -1 if no match is found. + */ +export const findIndexByPriority = ( + arr, + criteria, + usedIndexes = new Set(), + priorityKeys = ['id', 'label', 'type'], +) => { const validation = schema.safeParse({ arr, criteria }); if (!validation.success) { throw new TypeError(validation.error.message); } - if ('id' in criteria) { - return arr.findIndex( - (item, idx) => !usedIndexes.has(idx) && item?.id === criteria.id, - ); - } - if ('label' in criteria) { - return arr.findIndex( - (item, idx) => !usedIndexes.has(idx) && item?.label === criteria.label, - ); - } - if ('type' in criteria) { - return arr.findIndex( - (item, idx) => !usedIndexes.has(idx) && item?.type === criteria.type, - ); + for (const key of priorityKeys) { + if (key in criteria) { + return arr.findIndex( + (item, idx) => !usedIndexes.has(idx) && item[key] === criteria[key], + ); + } } + return -1; }; diff --git a/src/utils/get.js b/src/utils/get.js index e3384b4c..0e2513ab 100644 --- a/src/utils/get.js +++ b/src/utils/get.js @@ -1,5 +1,3 @@ -import { theme } from './theme'; - export const getNestedValue = (object, path = null) => { if (!path) return null; return path @@ -7,11 +5,11 @@ export const getNestedValue = (object, path = null) => { .reduce((acc, key) => (acc && acc[key] != null ? acc[key] : null), object); }; -export const getColor = (color) => { +export const getColor = (theme, color) => { return ( (typeof color === 'string' && color.startsWith('#') ? color - : getNestedValue(theme.get(), color)) ?? '#000' + : getNestedValue(theme, color)) ?? '#000' ); }; diff --git a/src/utils/renderer.js b/src/utils/renderer.js deleted file mode 100644 index e2ea9a9f..00000000 --- a/src/utils/renderer.js +++ /dev/null @@ -1,13 +0,0 @@ -const rendererStore = () => { - let _renderer = null; - - const set = (renderer) => { - _renderer = renderer; - }; - - const get = () => _renderer; - - return { set, get }; -}; - -export const renderer = rendererStore(); diff --git a/src/utils/theme.js b/src/utils/theme.js index e46a56aa..c02ab183 100644 --- a/src/utils/theme.js +++ b/src/utils/theme.js @@ -1,6 +1,6 @@ import { deepMerge } from './deepmerge/deepmerge'; -const themeStore = () => { +export const themeStore = () => { let _theme = { primary: { default: '#0C73BF', @@ -24,5 +24,3 @@ const themeStore = () => { return { set, get }; }; - -export const theme = themeStore();