feat(tools): add brush raster painting
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
documentSetLayerClippingMaskCommand,
|
documentSetLayerClippingMaskCommand,
|
||||||
documentSetLayerLockedCommand,
|
documentSetLayerLockedCommand,
|
||||||
documentSetLayerVisibleCommand,
|
documentSetLayerVisibleCommand,
|
||||||
|
documentUpdateAssetSourceCommand,
|
||||||
documentUngroupLayerCommand,
|
documentUngroupLayerCommand,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
|
|
||||||
@@ -52,6 +53,17 @@ describe("document commands", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("updates asset sources", () => {
|
||||||
|
const state = documentAddAssetCommand.execute(
|
||||||
|
{ state: createInitialAppState("Test") },
|
||||||
|
{ asset: { id: "asset-1", name: "Image", mimeType: "image/png", source: "old", intrinsicSize: { w: 100, h: 50 } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const next = documentUpdateAssetSourceCommand.execute({ state }, { assetId: "asset-1", source: "new" });
|
||||||
|
|
||||||
|
expect(next.document.assets[0]?.source).toBe("new");
|
||||||
|
});
|
||||||
|
|
||||||
test("adds image layers to artboards", () => {
|
test("adds image layers to artboards", () => {
|
||||||
const state = documentAddArtboardCommand.execute(
|
const state = documentAddArtboardCommand.execute(
|
||||||
{ state: createInitialAppState("Test") },
|
{ state: createInitialAppState("Test") },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Asset } from "@core/asset";
|
import type { Asset } from "@core/asset";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Rect } from "@core/geometry";
|
import type { Rect } from "@core/geometry";
|
||||||
import type { ArtboardId, LayerId } from "@core/id";
|
import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
||||||
import type { ImageLayer } from "@core/image-layer";
|
import type { ImageLayer } from "@core/image-layer";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import type { RasterLayer } from "@core/raster-layer";
|
import type { RasterLayer } from "@core/raster-layer";
|
||||||
@@ -43,6 +43,11 @@ export type DocumentAddAssetPayload = {
|
|||||||
asset: Asset;
|
asset: Asset;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DocumentUpdateAssetSourcePayload = {
|
||||||
|
assetId: AssetId;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DocumentAddImageLayerPayload = {
|
export type DocumentAddImageLayerPayload = {
|
||||||
artboardId: ArtboardId;
|
artboardId: ArtboardId;
|
||||||
parentGroupId?: LayerId;
|
parentGroupId?: LayerId;
|
||||||
@@ -219,6 +224,20 @@ export const documentAddAssetCommand: Command<DocumentAddAssetPayload> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const documentUpdateAssetSourceCommand: Command<DocumentUpdateAssetSourcePayload> = {
|
||||||
|
id: commandIds.documentUpdateAssetSource,
|
||||||
|
name: "Update asset source",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: {
|
||||||
|
...state.document,
|
||||||
|
assets: state.document.assets.map((asset) => (asset.id === payload.assetId ? { ...asset, source: payload.source } : asset)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const documentAddImageLayerCommand: Command<DocumentAddImageLayerPayload> = {
|
export const documentAddImageLayerCommand: Command<DocumentAddImageLayerPayload> = {
|
||||||
id: commandIds.documentAddImageLayer,
|
id: commandIds.documentAddImageLayer,
|
||||||
name: "Add image layer",
|
name: "Add image layer",
|
||||||
@@ -406,6 +425,7 @@ export const documentCommands = [
|
|||||||
documentSetArtboardLockedCommand,
|
documentSetArtboardLockedCommand,
|
||||||
documentRenameArtboardCommand,
|
documentRenameArtboardCommand,
|
||||||
documentAddAssetCommand,
|
documentAddAssetCommand,
|
||||||
|
documentUpdateAssetSourceCommand,
|
||||||
documentAddImageLayerCommand,
|
documentAddImageLayerCommand,
|
||||||
documentAddRasterLayerCommand,
|
documentAddRasterLayerCommand,
|
||||||
documentAddGroupLayerCommand,
|
documentAddGroupLayerCommand,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const commandIds = {
|
|||||||
documentSetArtboardLocked: "document.setArtboardLocked",
|
documentSetArtboardLocked: "document.setArtboardLocked",
|
||||||
documentRenameArtboard: "document.renameArtboard",
|
documentRenameArtboard: "document.renameArtboard",
|
||||||
documentAddAsset: "document.addAsset",
|
documentAddAsset: "document.addAsset",
|
||||||
|
documentUpdateAssetSource: "document.updateAssetSource",
|
||||||
documentAddImageLayer: "document.addImageLayer",
|
documentAddImageLayer: "document.addImageLayer",
|
||||||
documentAddRasterLayer: "document.addRasterLayer",
|
documentAddRasterLayer: "document.addRasterLayer",
|
||||||
documentAddGroupLayer: "document.addGroupLayer",
|
documentAddGroupLayer: "document.addGroupLayer",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export {
|
|||||||
documentSetLayerClippingMaskCommand,
|
documentSetLayerClippingMaskCommand,
|
||||||
documentSetLayerLockedCommand,
|
documentSetLayerLockedCommand,
|
||||||
documentSetLayerVisibleCommand,
|
documentSetLayerVisibleCommand,
|
||||||
|
documentUpdateAssetSourceCommand,
|
||||||
documentUngroupLayerCommand,
|
documentUngroupLayerCommand,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
export type {
|
export type {
|
||||||
@@ -38,6 +39,7 @@ export type {
|
|||||||
DocumentSetLayerClippingMaskPayload,
|
DocumentSetLayerClippingMaskPayload,
|
||||||
DocumentSetLayerLockedPayload,
|
DocumentSetLayerLockedPayload,
|
||||||
DocumentSetLayerVisiblePayload,
|
DocumentSetLayerVisiblePayload,
|
||||||
|
DocumentUpdateAssetSourcePayload,
|
||||||
DocumentUngroupLayerPayload,
|
DocumentUngroupLayerPayload,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
export { historyCommands, historyRedoCommand, historyUndoCommand } from "./history";
|
export { historyCommands, historyRedoCommand, historyUndoCommand } from "./history";
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
DocumentSetLayerClippingMaskPayload,
|
DocumentSetLayerClippingMaskPayload,
|
||||||
DocumentSetLayerLockedPayload,
|
DocumentSetLayerLockedPayload,
|
||||||
DocumentSetLayerVisiblePayload,
|
DocumentSetLayerVisiblePayload,
|
||||||
|
DocumentUpdateAssetSourcePayload,
|
||||||
DocumentUngroupLayerPayload,
|
DocumentUngroupLayerPayload,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
@@ -38,6 +39,7 @@ export type CommandPayloads = {
|
|||||||
[commandIds.documentSetArtboardLocked]: DocumentSetArtboardLockedPayload;
|
[commandIds.documentSetArtboardLocked]: DocumentSetArtboardLockedPayload;
|
||||||
[commandIds.documentRenameArtboard]: DocumentRenameArtboardPayload;
|
[commandIds.documentRenameArtboard]: DocumentRenameArtboardPayload;
|
||||||
[commandIds.documentAddAsset]: DocumentAddAssetPayload;
|
[commandIds.documentAddAsset]: DocumentAddAssetPayload;
|
||||||
|
[commandIds.documentUpdateAssetSource]: DocumentUpdateAssetSourcePayload;
|
||||||
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
|
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
|
||||||
[commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload;
|
[commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload;
|
||||||
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
|
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const availableToolIds = ["select", "crop", "pan"] as const;
|
export const availableToolIds = ["select", "crop", "brush", "pan"] as const;
|
||||||
|
|
||||||
export type ToolId = (typeof availableToolIds)[number];
|
export type ToolId = (typeof availableToolIds)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Crop, Cursor, Hand } from "@phosphor-icons/react";
|
import { Crop, Cursor, Hand, PaintBrush } from "@phosphor-icons/react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { InteractionMode, ToolId } from "@editor/tools";
|
import type { InteractionMode, ToolId } from "@editor/tools";
|
||||||
@@ -43,6 +43,8 @@ function iconForTool(tool: ToolId) {
|
|||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "crop":
|
case "crop":
|
||||||
return Crop;
|
return Crop;
|
||||||
|
case "brush":
|
||||||
|
return PaintBrush;
|
||||||
case "pan":
|
case "pan":
|
||||||
return Hand;
|
return Hand;
|
||||||
case "select":
|
case "select":
|
||||||
|
|||||||
105
view/canvas/brush.ts
Normal file
105
view/canvas/brush.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { ImageDocument } from "@core/document";
|
||||||
|
import type { Vec2D } from "@core/geometry";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
|
import type { RasterLayer } from "@core/raster-layer";
|
||||||
|
import type { EditorState } from "@editor/state";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
|
||||||
|
export type BrushSession = {
|
||||||
|
layerId: string;
|
||||||
|
previousPoint: Vec2D;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function beginBrushSession(document: ImageDocument, editor: EditorState, point: Vec2D): BrushSession | undefined {
|
||||||
|
if (editor.tools.activeTool !== "brush") return undefined;
|
||||||
|
const layerId = editor.selection.layerIds[0];
|
||||||
|
if (!layerId) return undefined;
|
||||||
|
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
||||||
|
if (!layer || layer.locked || !layer.visible) return undefined;
|
||||||
|
return { layerId, previousPoint: point };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBrushSession(options: {
|
||||||
|
store: AppStore;
|
||||||
|
session: BrushSession;
|
||||||
|
point: Vec2D;
|
||||||
|
color?: string;
|
||||||
|
size?: number;
|
||||||
|
}): Promise<BrushSession> {
|
||||||
|
const state = options.store.getState();
|
||||||
|
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
|
||||||
|
if (!layer) return { ...options.session, previousPoint: options.point };
|
||||||
|
|
||||||
|
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
|
if (!asset) return { ...options.session, previousPoint: options.point };
|
||||||
|
|
||||||
|
const source = await drawStroke({
|
||||||
|
source: asset.source,
|
||||||
|
width: asset.intrinsicSize.w,
|
||||||
|
height: asset.intrinsicSize.h,
|
||||||
|
from: documentPointToAssetPoint(options.session.previousPoint, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
||||||
|
to: documentPointToAssetPoint(options.point, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
||||||
|
color: options.color ?? "#111827",
|
||||||
|
size: options.size ?? 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: asset.id, source });
|
||||||
|
return { ...options.session, previousPoint: options.point };
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
|
||||||
|
return {
|
||||||
|
x: ((point.x - layer.transform.position.x) / Math.max(0.0001, layer.transform.scale.x) / width) * width,
|
||||||
|
y: ((point.y - layer.transform.position.y) / Math.max(0.0001, layer.transform.scale.y) / height) * height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawStroke(options: {
|
||||||
|
source: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
from: Vec2D;
|
||||||
|
to: Vec2D;
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
}) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.max(1, Math.round(options.width));
|
||||||
|
canvas.height = Math.max(1, Math.round(options.height));
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return options.source;
|
||||||
|
|
||||||
|
const image = await loadImage(options.source);
|
||||||
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
|
context.strokeStyle = options.color;
|
||||||
|
context.lineWidth = options.size;
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(options.from.x, options.from.y);
|
||||||
|
context.lineTo(options.to.x, options.to.y);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
return canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(source: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("Failed to load raster layer"));
|
||||||
|
image.src = source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined {
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.id === layerId && layer.type === "raster") return layer;
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const child = findRasterLayer(layer.children, layerId);
|
||||||
|
if (child) return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -5,6 +5,6 @@ import type { CanvasInputState } from "./useCanvasInput";
|
|||||||
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) {
|
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) {
|
||||||
if (input.isPanning) return "cursor-grabbing";
|
if (input.isPanning) return "cursor-grabbing";
|
||||||
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
|
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
|
||||||
if (interactionMode.type === "tool" && interactionMode.tool === "crop") return "cursor-crosshair";
|
if (interactionMode.type === "tool" && (interactionMode.tool === "crop" || interactionMode.tool === "brush")) return "cursor-crosshair";
|
||||||
return "cursor-default";
|
return "cursor-default";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, type RefObject } from "react";
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import { isPanInteractionMode } from "@editor/tools";
|
import { isPanInteractionMode } from "@editor/tools";
|
||||||
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
pointerInputEventFromPointerEvent,
|
pointerInputEventFromPointerEvent,
|
||||||
wheelInputEventFromWheelEvent,
|
wheelInputEventFromWheelEvent,
|
||||||
} from "@input/index";
|
} from "@input/index";
|
||||||
|
import { beginBrushSession, updateBrushSession, type BrushSession } from "./brush";
|
||||||
|
|
||||||
export type CanvasInputOptions = {
|
export type CanvasInputOptions = {
|
||||||
globalKeybindConsumer: GlobalKeybindConsumer;
|
globalKeybindConsumer: GlobalKeybindConsumer;
|
||||||
@@ -28,6 +29,7 @@ export function useCanvasInput(
|
|||||||
options: CanvasInputOptions,
|
options: CanvasInputOptions,
|
||||||
): CanvasInputState {
|
): CanvasInputState {
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
const brushSession = useRef<BrushSession>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -59,6 +61,15 @@ export function useCanvasInput(
|
|||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||||
|
const state = store.getState();
|
||||||
|
const brush = beginBrushSession(state.document, state.editor, viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport));
|
||||||
|
if (brush) {
|
||||||
|
brushSession.current = brush;
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const transformed = transformHandler.pointerDown(inputEvent);
|
const transformed = transformHandler.pointerDown(inputEvent);
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
canvas.setPointerCapture(event.pointerId);
|
canvas.setPointerCapture(event.pointerId);
|
||||||
@@ -74,12 +85,12 @@ export function useCanvasInput(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = store.getState();
|
const currentState = store.getState();
|
||||||
const selectionToolActive = state.editor.tools.activeTool === "select" || state.editor.tools.activeTool === "crop";
|
const selectionToolActive = currentState.editor.tools.activeTool === "select" || currentState.editor.tools.activeTool === "crop";
|
||||||
const selected = selectionToolActive && handleArtboardSelection({
|
const selected = selectionToolActive && handleArtboardSelection({
|
||||||
event: inputEvent,
|
event: inputEvent,
|
||||||
document: state.document,
|
document: currentState.document,
|
||||||
viewport: state.editor.viewport,
|
viewport: currentState.editor.viewport,
|
||||||
dispatch: store.dispatch,
|
dispatch: store.dispatch,
|
||||||
});
|
});
|
||||||
if (selected) event.preventDefault();
|
if (selected) event.preventDefault();
|
||||||
@@ -87,6 +98,15 @@ export function useCanvasInput(
|
|||||||
|
|
||||||
const handlePointerMove = (event: PointerEvent) => {
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||||
|
if (brushSession.current) {
|
||||||
|
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
|
||||||
|
void updateBrushSession({ store, session: brushSession.current, point }).then((nextSession) => {
|
||||||
|
brushSession.current = nextSession;
|
||||||
|
});
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const transformed = transformHandler.pointerMove(inputEvent);
|
const transformed = transformHandler.pointerMove(inputEvent);
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -99,6 +119,12 @@ export function useCanvasInput(
|
|||||||
|
|
||||||
const handlePointerUp = (event: PointerEvent) => {
|
const handlePointerUp = (event: PointerEvent) => {
|
||||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||||
|
if (brushSession.current) {
|
||||||
|
brushSession.current = undefined;
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const transformed = transformHandler.pointerUp(inputEvent);
|
const transformed = transformHandler.pointerUp(inputEvent);
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -144,3 +170,10 @@ export function useCanvasInput(
|
|||||||
|
|
||||||
return { isPanning };
|
return { isPanning };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function viewportPointToDocumentPoint(point: { x: number; y: number }, viewport: { center: { x: number; y: number }; size: { w: number; h: number }; zoom: number }) {
|
||||||
|
return {
|
||||||
|
x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,
|
||||||
|
y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export function labelForTool(tool: ToolId): string {
|
|||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "crop":
|
case "crop":
|
||||||
return "Crop";
|
return "Crop";
|
||||||
|
case "brush":
|
||||||
|
return "Brush";
|
||||||
case "pan":
|
case "pan":
|
||||||
return "Pan";
|
return "Pan";
|
||||||
case "select":
|
case "select":
|
||||||
|
|||||||
Reference in New Issue
Block a user