mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
Refactored the "Output Area Size" button to use the new setOutputAreaSize method: Correctly sets output area size and position based on current system logic (custom shape, extensions, outputAreaBounds) Fully functional across all editor modes New resizing logic: Operates relative to the current center of the output area Output area expands or contracts around its center without repositioning Ensures center remains unchanged as expected This fix provides precise control over layout dimensions and aligns with user expectations across workflows.
536 lines
21 KiB
JavaScript
536 lines
21 KiB
JavaScript
// @ts-ignore
|
|
import { api } from "../../scripts/api.js";
|
|
import { MaskTool } from "./MaskTool.js";
|
|
import { ShapeTool } from "./ShapeTool.js";
|
|
import { CustomShapeMenu } from "./CustomShapeMenu.js";
|
|
import { CanvasState } from "./CanvasState.js";
|
|
import { CanvasInteractions } from "./CanvasInteractions.js";
|
|
import { CanvasLayers } from "./CanvasLayers.js";
|
|
import { CanvasLayersPanel } from "./CanvasLayersPanel.js";
|
|
import { CanvasRenderer } from "./CanvasRenderer.js";
|
|
import { CanvasIO } from "./CanvasIO.js";
|
|
import { ImageReferenceManager } from "./ImageReferenceManager.js";
|
|
import { BatchPreviewManager } from "./BatchPreviewManager.js";
|
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
|
import { debounce, createCanvas } from "./utils/CommonUtils.js";
|
|
import { MaskEditorIntegration } from "./MaskEditorIntegration.js";
|
|
import { CanvasSelection } from "./CanvasSelection.js";
|
|
const useChainCallback = (original, next) => {
|
|
if (original === undefined || original === null) {
|
|
return next;
|
|
}
|
|
return function (...args) {
|
|
const originalReturn = original.apply(this, args);
|
|
const nextReturn = next.apply(this, args);
|
|
return nextReturn === undefined ? originalReturn : nextReturn;
|
|
};
|
|
};
|
|
const log = createModuleLogger('Canvas');
|
|
/**
|
|
* Canvas - Fasada dla systemu rysowania
|
|
*
|
|
* Klasa Canvas pełni rolę fasady, oferując uproszczony interfejs wysokiego poziomu
|
|
* dla złożonego systemu rysowania. Zamiast eksponować wszystkie metody modułów,
|
|
* udostępnia tylko kluczowe operacje i umożliwia bezpośredni dostęp do modułów
|
|
* gdy potrzebna jest bardziej szczegółowa kontrola.
|
|
*/
|
|
export class Canvas {
|
|
constructor(node, widget, callbacks = {}) {
|
|
this.node = node;
|
|
this.widget = widget;
|
|
const { canvas, ctx } = createCanvas(0, 0, '2d', { willReadFrequently: true });
|
|
if (!ctx)
|
|
throw new Error("Could not create canvas context");
|
|
this.canvas = canvas;
|
|
this.ctx = ctx;
|
|
this.width = 512;
|
|
this.height = 512;
|
|
this.layers = [];
|
|
this.onStateChange = callbacks.onStateChange;
|
|
this.onHistoryChange = callbacks.onHistoryChange;
|
|
this.lastMousePosition = { x: 0, y: 0 };
|
|
this.viewport = {
|
|
x: -(this.width / 1.5),
|
|
y: -(this.height / 2),
|
|
zoom: 0.8,
|
|
};
|
|
const { canvas: offscreenCanvas, ctx: offscreenCtx } = createCanvas(0, 0, '2d', {
|
|
alpha: false,
|
|
willReadFrequently: true
|
|
});
|
|
this.offscreenCanvas = offscreenCanvas;
|
|
this.offscreenCtx = offscreenCtx;
|
|
this.dataInitialized = false;
|
|
this.pendingDataCheck = null;
|
|
this.imageCache = new Map();
|
|
this.requestSaveState = () => { };
|
|
this.outputAreaShape = null;
|
|
this.autoApplyShapeMask = false;
|
|
this.shapeMaskExpansion = false;
|
|
this.shapeMaskExpansionValue = 0;
|
|
this.shapeMaskFeather = false;
|
|
this.shapeMaskFeatherValue = 0;
|
|
this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
|
this.outputAreaExtensionEnabled = false;
|
|
this.outputAreaExtensionPreview = null;
|
|
this.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
|
this.originalCanvasSize = { width: this.width, height: this.height };
|
|
this.originalOutputAreaPosition = { x: -(this.width / 4), y: -(this.height / 4) };
|
|
// Initialize outputAreaBounds centered in viewport, similar to how canvas resize/move work
|
|
this.outputAreaBounds = {
|
|
x: -(this.width / 4),
|
|
y: -(this.height / 4),
|
|
width: this.width,
|
|
height: this.height
|
|
};
|
|
this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange });
|
|
this.shapeTool = new ShapeTool(this);
|
|
this.customShapeMenu = new CustomShapeMenu(this);
|
|
this.maskEditorIntegration = new MaskEditorIntegration(this);
|
|
this.canvasState = new CanvasState(this);
|
|
this.canvasSelection = new CanvasSelection(this);
|
|
this.canvasInteractions = new CanvasInteractions(this);
|
|
this.canvasLayers = new CanvasLayers(this);
|
|
this.canvasLayersPanel = new CanvasLayersPanel(this);
|
|
this.canvasRenderer = new CanvasRenderer(this);
|
|
this.canvasIO = new CanvasIO(this);
|
|
this.imageReferenceManager = new ImageReferenceManager(this);
|
|
this.batchPreviewManagers = [];
|
|
this.pendingBatchContext = null;
|
|
this.interaction = this.canvasInteractions.interaction;
|
|
this.previewVisible = false;
|
|
this.isMouseOver = false;
|
|
this._initializeModules();
|
|
this._setupCanvas();
|
|
log.debug('Canvas widget element:', this.node);
|
|
log.info('Canvas initialized', {
|
|
nodeId: this.node.id,
|
|
dimensions: { width: this.width, height: this.height },
|
|
viewport: this.viewport
|
|
});
|
|
this.previewVisible = false;
|
|
}
|
|
async waitForWidget(name, node, interval = 100, timeout = 20000) {
|
|
const startTime = Date.now();
|
|
return new Promise((resolve, reject) => {
|
|
const check = () => {
|
|
const widget = node.widgets.find((w) => w.name === name);
|
|
if (widget) {
|
|
resolve(widget);
|
|
}
|
|
else if (Date.now() - startTime > timeout) {
|
|
reject(new Error(`Widget "${name}" not found within timeout.`));
|
|
}
|
|
else {
|
|
setTimeout(check, interval);
|
|
}
|
|
};
|
|
check();
|
|
});
|
|
}
|
|
/**
|
|
* Kontroluje widoczność podglądu canvas
|
|
* @param {boolean} visible - Czy podgląd ma być widoczny
|
|
*/
|
|
async setPreviewVisibility(visible) {
|
|
this.previewVisible = visible;
|
|
log.info("Canvas preview visibility set to:", visible);
|
|
const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node);
|
|
if (imagePreviewWidget) {
|
|
log.debug("Found $$canvas-image-preview widget, controlling visibility");
|
|
if (visible) {
|
|
if (imagePreviewWidget.options) {
|
|
imagePreviewWidget.options.hidden = false;
|
|
}
|
|
if ('visible' in imagePreviewWidget) {
|
|
imagePreviewWidget.visible = true;
|
|
}
|
|
if ('hidden' in imagePreviewWidget) {
|
|
imagePreviewWidget.hidden = false;
|
|
}
|
|
imagePreviewWidget.computeSize = function () {
|
|
return [0, 250]; // Szerokość 0 (auto), wysokość 250
|
|
};
|
|
}
|
|
else {
|
|
if (imagePreviewWidget.options) {
|
|
imagePreviewWidget.options.hidden = true;
|
|
}
|
|
if ('visible' in imagePreviewWidget) {
|
|
imagePreviewWidget.visible = false;
|
|
}
|
|
if ('hidden' in imagePreviewWidget) {
|
|
imagePreviewWidget.hidden = true;
|
|
}
|
|
imagePreviewWidget.computeSize = function () {
|
|
return [0, 0]; // Szerokość 0, wysokość 0
|
|
};
|
|
}
|
|
this.render();
|
|
}
|
|
else {
|
|
log.warn("$$canvas-image-preview widget not found in Canvas.js");
|
|
}
|
|
}
|
|
/**
|
|
* Inicjalizuje moduły systemu canvas
|
|
* @private
|
|
*/
|
|
_initializeModules() {
|
|
log.debug('Initializing Canvas modules...');
|
|
// Stwórz opóźnioną wersję funkcji zapisu stanu
|
|
this.requestSaveState = debounce(() => this.saveState(), 500);
|
|
this._setupAutoRefreshHandlers();
|
|
log.debug('Canvas modules initialized successfully');
|
|
}
|
|
/**
|
|
* Konfiguruje podstawowe właściwości canvas
|
|
* @private
|
|
*/
|
|
_setupCanvas() {
|
|
this.initCanvas();
|
|
this.canvasInteractions.setupEventListeners();
|
|
this.canvasIO.initNodeData();
|
|
this.layers = this.layers.map((layer) => ({
|
|
...layer,
|
|
opacity: 1
|
|
}));
|
|
}
|
|
/**
|
|
* Ładuje stan canvas z bazy danych
|
|
*/
|
|
async loadInitialState() {
|
|
log.info("Loading initial state for node:", this.node.id);
|
|
const loaded = await this.canvasState.loadStateFromDB();
|
|
if (!loaded) {
|
|
log.info("No saved state found, initializing from node data.");
|
|
await this.canvasIO.initNodeData();
|
|
}
|
|
this.saveState();
|
|
this.render();
|
|
// Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu
|
|
if (this.canvasLayersPanel) {
|
|
this.canvasLayersPanel.onLayersChanged();
|
|
}
|
|
}
|
|
/**
|
|
* Zapisuje obecny stan
|
|
* @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii
|
|
*/
|
|
saveState(replaceLast = false) {
|
|
log.debug('Saving canvas state', { replaceLast, layersCount: this.layers.length });
|
|
this.canvasState.saveState(replaceLast);
|
|
this.incrementOperationCount();
|
|
this._notifyStateChange();
|
|
}
|
|
/**
|
|
* Cofnij ostatnią operację
|
|
*/
|
|
undo() {
|
|
log.info('Performing undo operation');
|
|
const historyInfo = this.canvasState.getHistoryInfo();
|
|
log.debug('History state before undo:', historyInfo);
|
|
this.canvasState.undo();
|
|
this.incrementOperationCount();
|
|
this._notifyStateChange();
|
|
// Powiadom panel warstw o zmianie stanu warstw
|
|
if (this.canvasLayersPanel) {
|
|
this.canvasLayersPanel.onLayersChanged();
|
|
this.canvasLayersPanel.onSelectionChanged();
|
|
}
|
|
log.debug('Undo completed, layers count:', this.layers.length);
|
|
}
|
|
/**
|
|
* Ponów cofniętą operację
|
|
*/
|
|
redo() {
|
|
log.info('Performing redo operation');
|
|
const historyInfo = this.canvasState.getHistoryInfo();
|
|
log.debug('History state before redo:', historyInfo);
|
|
this.canvasState.redo();
|
|
this.incrementOperationCount();
|
|
this._notifyStateChange();
|
|
// Powiadom panel warstw o zmianie stanu warstw
|
|
if (this.canvasLayersPanel) {
|
|
this.canvasLayersPanel.onLayersChanged();
|
|
this.canvasLayersPanel.onSelectionChanged();
|
|
}
|
|
log.debug('Redo completed, layers count:', this.layers.length);
|
|
}
|
|
/**
|
|
* Renderuje canvas
|
|
*/
|
|
render() {
|
|
this.canvasRenderer.render();
|
|
}
|
|
/**
|
|
* Dodaje warstwę z obrazem
|
|
* @param {Image} image - Obraz do dodania
|
|
* @param {Object} layerProps - Właściwości warstwy
|
|
* @param {string} addMode - Tryb dodawania
|
|
*/
|
|
async addLayer(image, layerProps = {}, addMode = 'default') {
|
|
const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
|
|
// Powiadom panel warstw o dodaniu nowej warstwy
|
|
if (this.canvasLayersPanel) {
|
|
this.canvasLayersPanel.onLayersChanged();
|
|
}
|
|
return result;
|
|
}
|
|
/**
|
|
* Usuwa wybrane warstwy
|
|
*/
|
|
removeLayersByIds(layerIds) {
|
|
if (!layerIds || layerIds.length === 0)
|
|
return;
|
|
const initialCount = this.layers.length;
|
|
this.saveState();
|
|
this.layers = this.layers.filter((l) => !layerIds.includes(l.id));
|
|
// If the current selection was part of the removal, clear it
|
|
const newSelection = this.canvasSelection.selectedLayers.filter((l) => !layerIds.includes(l.id));
|
|
this.canvasSelection.updateSelection(newSelection);
|
|
this.render();
|
|
this.saveState();
|
|
if (this.canvasLayersPanel) {
|
|
this.canvasLayersPanel.onLayersChanged();
|
|
}
|
|
log.info(`Removed ${initialCount - this.layers.length} layers by ID.`);
|
|
}
|
|
removeSelectedLayers() {
|
|
return this.canvasSelection.removeSelectedLayers();
|
|
}
|
|
/**
|
|
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
|
|
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
|
|
* @param {Array} newSelection - Nowa lista zaznaczonych warstw
|
|
*/
|
|
updateSelection(newSelection) {
|
|
return this.canvasSelection.updateSelection(newSelection);
|
|
}
|
|
/**
|
|
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
|
|
*/
|
|
updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) {
|
|
return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
|
}
|
|
defineOutputAreaWithShape(shape) {
|
|
this.canvasInteractions.defineOutputAreaWithShape(shape);
|
|
}
|
|
/**
|
|
* Zmienia rozmiar obszaru wyjściowego
|
|
* @param {number} width - Nowa szerokość
|
|
* @param {number} height - Nowa wysokość
|
|
* @param {boolean} saveHistory - Czy zapisać w historii
|
|
*/
|
|
updateOutputAreaSize(width, height, saveHistory = true) {
|
|
const result = this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
|
|
// Update mask canvas to ensure it covers the new output area
|
|
this.maskTool.updateMaskCanvasForOutputArea();
|
|
return result;
|
|
}
|
|
/**
|
|
* Ustawia nowy rozmiar output area zgodnie z nowym systemem (resetuje rozszerzenia, pozycję, rozmiar)
|
|
*/
|
|
setOutputAreaSize(width, height) {
|
|
// Reset rozszerzeń
|
|
this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
|
this.outputAreaExtensionEnabled = false;
|
|
this.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
|
// Oblicz środek obecnego output area
|
|
const prevBounds = this.outputAreaBounds;
|
|
const centerX = prevBounds.x + prevBounds.width / 2;
|
|
const centerY = prevBounds.y + prevBounds.height / 2;
|
|
// Nowa pozycja lewego górnego rogu, by środek pozostał w miejscu
|
|
const newX = centerX - width / 2;
|
|
const newY = centerY - height / 2;
|
|
// Ustaw nowy rozmiar bazowy i pozycję
|
|
this.originalCanvasSize = { width, height };
|
|
this.originalOutputAreaPosition = { x: newX, y: newY };
|
|
// Ustaw outputAreaBounds na nowy rozmiar i pozycję
|
|
this.outputAreaBounds = {
|
|
x: newX,
|
|
y: newY,
|
|
width,
|
|
height
|
|
};
|
|
// Zaktualizuj rozmiar przez istniejącą metodę (ustawia maskę, itp.)
|
|
this.updateOutputAreaSize(width, height, true);
|
|
this.render();
|
|
this.saveState();
|
|
}
|
|
/**
|
|
* Eksportuje spłaszczony canvas jako blob
|
|
*/
|
|
async getFlattenedCanvasAsBlob() {
|
|
return this.canvasLayers.getFlattenedCanvasAsBlob();
|
|
}
|
|
/**
|
|
* Eksportuje spłaszczony canvas z maską jako kanałem alpha
|
|
*/
|
|
async getFlattenedCanvasWithMaskAsBlob() {
|
|
return this.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
|
}
|
|
/**
|
|
* Importuje najnowszy obraz
|
|
*/
|
|
async importLatestImage() {
|
|
return this.canvasIO.importLatestImage();
|
|
}
|
|
_setupAutoRefreshHandlers() {
|
|
let lastExecutionStartTime = 0;
|
|
// Helper function to get auto-refresh value from node widget
|
|
const getAutoRefreshValue = () => {
|
|
const widget = this.node.widgets.find((w) => w.name === 'auto_refresh_after_generation');
|
|
return widget ? widget.value : false;
|
|
};
|
|
const handleExecutionStart = () => {
|
|
if (getAutoRefreshValue()) {
|
|
lastExecutionStartTime = Date.now();
|
|
// Store a snapshot of the context for the upcoming batch
|
|
this.pendingBatchContext = {
|
|
// For the menu position - position relative to outputAreaBounds, not canvas center
|
|
spawnPosition: {
|
|
x: this.outputAreaBounds.x + this.outputAreaBounds.width / 2,
|
|
y: this.outputAreaBounds.y + this.outputAreaBounds.height
|
|
},
|
|
// For the image placement - use actual outputAreaBounds instead of hardcoded (0,0)
|
|
outputArea: {
|
|
x: this.outputAreaBounds.x,
|
|
y: this.outputAreaBounds.y,
|
|
width: this.outputAreaBounds.width,
|
|
height: this.outputAreaBounds.height
|
|
}
|
|
};
|
|
log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext);
|
|
this.render(); // Trigger render to show the pending outline immediately
|
|
}
|
|
};
|
|
const handleExecutionSuccess = async () => {
|
|
if (getAutoRefreshValue()) {
|
|
log.info('Auto-refresh triggered, importing latest images.');
|
|
if (!this.pendingBatchContext) {
|
|
log.warn("execution_start did not fire, cannot process batch. Awaiting next execution.");
|
|
return;
|
|
}
|
|
// Use the captured output area for image import
|
|
const newLayers = await this.canvasIO.importLatestImages(lastExecutionStartTime, this.pendingBatchContext.outputArea);
|
|
if (newLayers && newLayers.length > 1) {
|
|
const newManager = new BatchPreviewManager(this, this.pendingBatchContext.spawnPosition, this.pendingBatchContext.outputArea);
|
|
this.batchPreviewManagers.push(newManager);
|
|
newManager.show(newLayers);
|
|
}
|
|
// Consume the context
|
|
this.pendingBatchContext = null;
|
|
// Final render to clear the outline if it was the last one
|
|
this.render();
|
|
}
|
|
};
|
|
api.addEventListener('execution_start', handleExecutionStart);
|
|
api.addEventListener('execution_success', handleExecutionSuccess);
|
|
this.node.onRemoved = useChainCallback(this.node.onRemoved, () => {
|
|
log.info('Node removed, cleaning up auto-refresh listeners.');
|
|
api.removeEventListener('execution_start', handleExecutionStart);
|
|
api.removeEventListener('execution_success', handleExecutionSuccess);
|
|
});
|
|
log.debug('Auto-refresh handlers setup complete, reading from node widget: auto_refresh_after_generation');
|
|
}
|
|
/**
|
|
* Uruchamia edytor masek
|
|
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora
|
|
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
|
|
*/
|
|
async startMaskEditor(predefinedMask = null, sendCleanImage = true) {
|
|
return this.maskEditorIntegration.startMaskEditor(predefinedMask, sendCleanImage);
|
|
}
|
|
/**
|
|
* Inicjalizuje podstawowe właściwości canvas
|
|
*/
|
|
initCanvas() {
|
|
this.canvas.width = this.width;
|
|
this.canvas.height = this.height;
|
|
this.canvas.style.border = '1px solid black';
|
|
this.canvas.style.maxWidth = '100%';
|
|
this.canvas.style.backgroundColor = '#606060';
|
|
this.canvas.style.width = '100%';
|
|
this.canvas.style.height = '100%';
|
|
this.canvas.tabIndex = 0;
|
|
this.canvas.style.outline = 'none';
|
|
}
|
|
/**
|
|
* Pobiera współrzędne myszy w układzie świata
|
|
* @param {MouseEvent} e - Zdarzenie myszy
|
|
*/
|
|
getMouseWorldCoordinates(e) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const mouseX_DOM = e.clientX - rect.left;
|
|
const mouseY_DOM = e.clientY - rect.top;
|
|
if (!this.offscreenCanvas)
|
|
throw new Error("Offscreen canvas not initialized");
|
|
const scaleX = this.offscreenCanvas.width / rect.width;
|
|
const scaleY = this.offscreenCanvas.height / rect.height;
|
|
const mouseX_Buffer = mouseX_DOM * scaleX;
|
|
const mouseY_Buffer = mouseY_DOM * scaleY;
|
|
const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x;
|
|
const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y;
|
|
return { x: worldX, y: worldY };
|
|
}
|
|
/**
|
|
* Pobiera współrzędne myszy w układzie widoku
|
|
* @param {MouseEvent} e - Zdarzenie myszy
|
|
*/
|
|
getMouseViewCoordinates(e) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const mouseX_DOM = e.clientX - rect.left;
|
|
const mouseY_DOM = e.clientY - rect.top;
|
|
const scaleX = this.canvas.width / rect.width;
|
|
const scaleY = this.canvas.height / rect.height;
|
|
const mouseX_Canvas = mouseX_DOM * scaleX;
|
|
const mouseY_Canvas = mouseY_DOM * scaleY;
|
|
return { x: mouseX_Canvas, y: mouseY_Canvas };
|
|
}
|
|
/**
|
|
* Aktualizuje zaznaczenie po operacji historii
|
|
*/
|
|
updateSelectionAfterHistory() {
|
|
return this.canvasSelection.updateSelectionAfterHistory();
|
|
}
|
|
/**
|
|
* Aktualizuje przyciski historii
|
|
*/
|
|
updateHistoryButtons() {
|
|
if (this.onHistoryChange) {
|
|
const historyInfo = this.canvasState.getHistoryInfo();
|
|
this.onHistoryChange({
|
|
canUndo: historyInfo.canUndo,
|
|
canRedo: historyInfo.canRedo
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Zwiększa licznik operacji (dla garbage collection)
|
|
*/
|
|
incrementOperationCount() {
|
|
if (this.imageReferenceManager) {
|
|
this.imageReferenceManager.incrementOperationCount();
|
|
}
|
|
}
|
|
/**
|
|
* Czyści zasoby canvas
|
|
*/
|
|
destroy() {
|
|
if (this.imageReferenceManager) {
|
|
this.imageReferenceManager.destroy();
|
|
}
|
|
log.info("Canvas destroyed");
|
|
}
|
|
/**
|
|
* Powiadamia o zmianie stanu
|
|
* @private
|
|
*/
|
|
_notifyStateChange() {
|
|
if (this.onStateChange) {
|
|
this.onStateChange();
|
|
}
|
|
}
|
|
}
|