adds commands, brush, history
This commit is contained in:
parent
f38481efee
commit
a5659380c4
14 changed files with 412 additions and 47 deletions
1
components/canvas/bounds-bg.tsx
Normal file
1
components/canvas/bounds-bg.tsx
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export default function BoundsBg() {}
|
1
components/canvas/bounds.tsx
Normal file
1
components/canvas/bounds.tsx
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export default function Bounds() {}
|
23
components/canvas/brush.tsx
Normal file
23
components/canvas/brush.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { useSelector } from "state"
|
||||||
|
import styled from "styles"
|
||||||
|
|
||||||
|
export default function Brush() {
|
||||||
|
const brush = useSelector(({ data }) => data.brush)
|
||||||
|
|
||||||
|
if (!brush) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrushRect
|
||||||
|
x={brush.minX}
|
||||||
|
y={brush.minY}
|
||||||
|
width={brush.width}
|
||||||
|
height={brush.height}
|
||||||
|
className="brush"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BrushRect = styled("rect", {
|
||||||
|
fill: "$brushFill",
|
||||||
|
stroke: "$brushStroke",
|
||||||
|
})
|
|
@ -1,8 +1,11 @@
|
||||||
import styled from "styles"
|
import styled from "styles"
|
||||||
import { useRef } from "react"
|
import { getPointerEventInfo } from "utils/utils"
|
||||||
|
import React, { useCallback, useRef } from "react"
|
||||||
import useZoomEvents from "hooks/useZoomEvents"
|
import useZoomEvents from "hooks/useZoomEvents"
|
||||||
import useCamera from "hooks/useCamera"
|
import useCamera from "hooks/useCamera"
|
||||||
import Page from "./page"
|
import Page from "./page"
|
||||||
|
import Brush from "./brush"
|
||||||
|
import state from "state"
|
||||||
|
|
||||||
export default function Canvas() {
|
export default function Canvas() {
|
||||||
const rCanvas = useRef<SVGSVGElement>(null)
|
const rCanvas = useRef<SVGSVGElement>(null)
|
||||||
|
@ -11,10 +14,31 @@ export default function Canvas() {
|
||||||
|
|
||||||
useCamera(rGroup)
|
useCamera(rGroup)
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
|
rCanvas.current.setPointerCapture(e.pointerId)
|
||||||
|
state.send("POINTED_CANVAS", getPointerEventInfo(e))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||||
|
state.send("MOVED_POINTER", getPointerEventInfo(e))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||||
|
rCanvas.current.releasePointerCapture(e.pointerId)
|
||||||
|
state.send("STOPPED_POINTING", getPointerEventInfo(e))
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainSVG ref={rCanvas} {...events}>
|
<MainSVG
|
||||||
|
ref={rCanvas}
|
||||||
|
{...events}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
>
|
||||||
<MainGroup ref={rGroup}>
|
<MainGroup ref={rGroup}>
|
||||||
<Page />
|
<Page />
|
||||||
|
<Brush />
|
||||||
</MainGroup>
|
</MainGroup>
|
||||||
</MainSVG>
|
</MainSVG>
|
||||||
)
|
)
|
||||||
|
|
94
state/commands/command.ts
Normal file
94
state/commands/command.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import { Data } from "types"
|
||||||
|
|
||||||
|
/* ------------------ Command Class ----------------- */
|
||||||
|
|
||||||
|
export type CommandFn<T> = (data: T, initial?: boolean) => void
|
||||||
|
|
||||||
|
export enum CommandType {
|
||||||
|
ChangeBounds,
|
||||||
|
CreateGlob,
|
||||||
|
CreateNode,
|
||||||
|
Delete,
|
||||||
|
Split,
|
||||||
|
Move,
|
||||||
|
MoveAnchor,
|
||||||
|
ReorderGlobs,
|
||||||
|
ReorderNodes,
|
||||||
|
Paste,
|
||||||
|
ToggleCap,
|
||||||
|
ToggleLocked,
|
||||||
|
SetProperty,
|
||||||
|
SetItems,
|
||||||
|
Transform,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command makes changes to some applicate state. Every command has an "undo"
|
||||||
|
* method to reverse its changes. The apps history is a series of commands.
|
||||||
|
*/
|
||||||
|
export class BaseCommand<T extends any> {
|
||||||
|
timestamp = Date.now()
|
||||||
|
private undoFn: CommandFn<T>
|
||||||
|
private doFn: CommandFn<T>
|
||||||
|
protected restoreBeforeSelectionState: (data: T) => void
|
||||||
|
protected restoreAfterSelectionState: (data: T) => void
|
||||||
|
protected saveSelectionState: (data: T) => (data: T) => void
|
||||||
|
protected manualSelection: boolean
|
||||||
|
|
||||||
|
constructor(options: {
|
||||||
|
type: CommandType
|
||||||
|
do: CommandFn<T>
|
||||||
|
undo: CommandFn<T>
|
||||||
|
manualSelection?: boolean
|
||||||
|
}) {
|
||||||
|
this.doFn = options.do
|
||||||
|
this.undoFn = options.undo
|
||||||
|
this.manualSelection = options.manualSelection || false
|
||||||
|
this.restoreBeforeSelectionState = () => () => {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
this.restoreAfterSelectionState = () => () => {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo = (data: T) => {
|
||||||
|
if (this.manualSelection) {
|
||||||
|
this.undoFn(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to set the selection state to what it was before we after we did the command
|
||||||
|
this.restoreAfterSelectionState(data)
|
||||||
|
this.undoFn(data)
|
||||||
|
this.restoreBeforeSelectionState(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
redo = (data: T, initial = false) => {
|
||||||
|
if (initial) {
|
||||||
|
this.restoreBeforeSelectionState = this.saveSelectionState(data)
|
||||||
|
} else {
|
||||||
|
this.restoreBeforeSelectionState(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to set the selection state to what it was before we did the command
|
||||||
|
this.doFn(data, initial)
|
||||||
|
|
||||||
|
if (initial) {
|
||||||
|
this.restoreAfterSelectionState = this.saveSelectionState(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Project Specific ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subclass of BaseCommand that sends events to our state. In our case, we want our actions
|
||||||
|
* to mutate the state's data. Actions do not effect the "active states" in
|
||||||
|
* the app.
|
||||||
|
*/
|
||||||
|
export class Command extends BaseCommand<Data> {
|
||||||
|
saveSelectionState = (data: Data) => {
|
||||||
|
return (data: Data) => {}
|
||||||
|
}
|
||||||
|
}
|
63
state/commands/history.ts
Normal file
63
state/commands/history.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { Data } from "types"
|
||||||
|
import { BaseCommand } from "./command"
|
||||||
|
|
||||||
|
class BaseHistory<T> {
|
||||||
|
private stack: BaseCommand<T>[] = []
|
||||||
|
private pointer = -1
|
||||||
|
private maxLength = 100
|
||||||
|
private _enabled = true
|
||||||
|
|
||||||
|
execute = (data: T, command: BaseCommand<T>) => {
|
||||||
|
if (this.disabled) return
|
||||||
|
this.stack = this.stack.slice(0, this.pointer + 1)
|
||||||
|
this.stack.push(command)
|
||||||
|
command.redo(data, true)
|
||||||
|
this.pointer++
|
||||||
|
|
||||||
|
if (this.stack.length > this.maxLength) {
|
||||||
|
this.stack = this.stack.slice(this.stack.length - this.maxLength)
|
||||||
|
this.pointer = this.maxLength - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
this.save(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
undo = (data: T) => {
|
||||||
|
if (this.disabled) return
|
||||||
|
if (this.pointer === -1) return
|
||||||
|
const command = this.stack[this.pointer]
|
||||||
|
command.undo(data)
|
||||||
|
this.pointer--
|
||||||
|
this.save(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
redo = (data: T) => {
|
||||||
|
if (this.disabled) return
|
||||||
|
if (this.pointer === this.stack.length - 1) return
|
||||||
|
const command = this.stack[this.pointer + 1]
|
||||||
|
command.redo(data, false)
|
||||||
|
this.pointer++
|
||||||
|
this.save(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
save = (data: T) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
|
||||||
|
localStorage.setItem("glob_aldata_v6", JSON.stringify(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
disable = () => {
|
||||||
|
this._enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
enable = () => {
|
||||||
|
this._enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
get disabled() {
|
||||||
|
return !this._enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new BaseHistory<Data>()
|
44
state/data.ts
Normal file
44
state/data.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { Data, ShapeType } from "types"
|
||||||
|
|
||||||
|
export const defaultDocument: Data["document"] = {
|
||||||
|
pages: {
|
||||||
|
page0: {
|
||||||
|
id: "page0",
|
||||||
|
type: "page",
|
||||||
|
name: "Page 0",
|
||||||
|
childIndex: 0,
|
||||||
|
shapes: {
|
||||||
|
shape0: {
|
||||||
|
id: "shape0",
|
||||||
|
type: ShapeType.Circle,
|
||||||
|
name: "Shape 0",
|
||||||
|
parentId: "page0",
|
||||||
|
childIndex: 1,
|
||||||
|
point: [100, 100],
|
||||||
|
radius: 50,
|
||||||
|
rotation: 0,
|
||||||
|
},
|
||||||
|
shape1: {
|
||||||
|
id: "shape1",
|
||||||
|
type: ShapeType.Rectangle,
|
||||||
|
name: "Shape 1",
|
||||||
|
parentId: "page0",
|
||||||
|
childIndex: 1,
|
||||||
|
point: [300, 300],
|
||||||
|
size: [200, 200],
|
||||||
|
rotation: 0,
|
||||||
|
},
|
||||||
|
shape2: {
|
||||||
|
id: "shape2",
|
||||||
|
type: ShapeType.Circle,
|
||||||
|
name: "Shape 2",
|
||||||
|
parentId: "page0",
|
||||||
|
childIndex: 2,
|
||||||
|
point: [200, 800],
|
||||||
|
radius: 25,
|
||||||
|
rotation: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
17
state/sessions/base-session.ts
Normal file
17
state/sessions/base-session.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { Data } from "types"
|
||||||
|
|
||||||
|
export default class BaseSession {
|
||||||
|
constructor(data: Data) {}
|
||||||
|
|
||||||
|
update = (data: Data, ...args: unknown[]) => {
|
||||||
|
// Update the state
|
||||||
|
}
|
||||||
|
|
||||||
|
complete = (data: Data, ...args: unknown[]) => {
|
||||||
|
// Create a command
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel = (data: Data) => {
|
||||||
|
// Clean up the change
|
||||||
|
}
|
||||||
|
}
|
64
state/sessions/brush-session.ts
Normal file
64
state/sessions/brush-session.ts
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import { current } from "immer"
|
||||||
|
import { Bounds, Data, Shape } from "types"
|
||||||
|
import BaseSession from "./base-session"
|
||||||
|
import { screenToWorld, getBoundsFromPoints } from "utils/utils"
|
||||||
|
import * as vec from "utils/vec"
|
||||||
|
|
||||||
|
interface BrushSnapshot {
|
||||||
|
selectedIds: string[]
|
||||||
|
shapes: Shape[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class BrushSession extends BaseSession {
|
||||||
|
origin: number[]
|
||||||
|
snapshot: BrushSnapshot
|
||||||
|
|
||||||
|
constructor(data: Data, point: number[]) {
|
||||||
|
super(data)
|
||||||
|
|
||||||
|
this.origin = vec.round(point)
|
||||||
|
|
||||||
|
this.snapshot = BrushSession.getSnapshot(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
update = (data: Data, point: number[]) => {
|
||||||
|
const { origin, snapshot } = this
|
||||||
|
|
||||||
|
const bounds = getBoundsFromPoints(origin, point)
|
||||||
|
|
||||||
|
data.brush = bounds
|
||||||
|
|
||||||
|
const { minX: x, minY: y, width: w, height: h } = bounds
|
||||||
|
|
||||||
|
data.selectedIds = [
|
||||||
|
...snapshot.selectedIds,
|
||||||
|
...snapshot.shapes.map((shape) => {
|
||||||
|
return shape.id
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Narrow the the items on the screen
|
||||||
|
data.brush = bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel = (data: Data) => {
|
||||||
|
data.brush = undefined
|
||||||
|
data.selectedIds = this.snapshot.selectedIds
|
||||||
|
}
|
||||||
|
|
||||||
|
complete = (data: Data) => {
|
||||||
|
data.brush = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSnapshot(data: Data) {
|
||||||
|
const {
|
||||||
|
document: { pages },
|
||||||
|
currentPageId,
|
||||||
|
} = current(data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedIds: [...data.selectedIds],
|
||||||
|
shapes: Object.values(pages[currentPageId].shapes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
4
state/sessions/index.ts
Normal file
4
state/sessions/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import BaseSession from "./brush-session"
|
||||||
|
import BrushSession from "./brush-session"
|
||||||
|
|
||||||
|
export { BrushSession, BaseSession }
|
|
@ -1,56 +1,20 @@
|
||||||
import { createSelectorHook, createState } from "@state-designer/react"
|
import { createSelectorHook, createState } from "@state-designer/react"
|
||||||
import { clamp, screenToWorld } from "utils/utils"
|
import { clamp, screenToWorld } from "utils/utils"
|
||||||
import * as vec from "utils/vec"
|
import * as vec from "utils/vec"
|
||||||
import { Data, ShapeType } from "types"
|
import { Data } from "types"
|
||||||
|
import { defaultDocument } from "./data"
|
||||||
|
import * as Sessions from "./sessions"
|
||||||
|
|
||||||
const initialData: Data = {
|
const initialData: Data = {
|
||||||
camera: {
|
camera: {
|
||||||
point: [0, 0],
|
point: [0, 0],
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
},
|
},
|
||||||
|
brush: undefined,
|
||||||
|
pointedId: null,
|
||||||
|
selectedIds: [],
|
||||||
currentPageId: "page0",
|
currentPageId: "page0",
|
||||||
document: {
|
document: defaultDocument,
|
||||||
pages: {
|
|
||||||
page0: {
|
|
||||||
id: "page0",
|
|
||||||
type: "page",
|
|
||||||
name: "Page 0",
|
|
||||||
childIndex: 0,
|
|
||||||
shapes: {
|
|
||||||
shape0: {
|
|
||||||
id: "shape0",
|
|
||||||
type: ShapeType.Circle,
|
|
||||||
name: "Shape 0",
|
|
||||||
parentId: "page0",
|
|
||||||
childIndex: 1,
|
|
||||||
point: [100, 100],
|
|
||||||
radius: 50,
|
|
||||||
rotation: 0,
|
|
||||||
},
|
|
||||||
shape1: {
|
|
||||||
id: "shape1",
|
|
||||||
type: ShapeType.Rectangle,
|
|
||||||
name: "Shape 1",
|
|
||||||
parentId: "page0",
|
|
||||||
childIndex: 1,
|
|
||||||
point: [300, 300],
|
|
||||||
size: [200, 200],
|
|
||||||
rotation: 0,
|
|
||||||
},
|
|
||||||
shape2: {
|
|
||||||
id: "shape2",
|
|
||||||
type: ShapeType.Circle,
|
|
||||||
name: "Shape 2",
|
|
||||||
parentId: "page0",
|
|
||||||
childIndex: 2,
|
|
||||||
point: [200, 800],
|
|
||||||
radius: 25,
|
|
||||||
rotation: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = createState({
|
const state = createState({
|
||||||
|
@ -63,7 +27,37 @@ const state = createState({
|
||||||
do: "panCamera",
|
do: "panCamera",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
initial: "selecting",
|
||||||
|
states: {
|
||||||
|
selecting: {
|
||||||
|
on: {
|
||||||
|
POINTED_CANVAS: { to: "brushSelecting" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
brushSelecting: {
|
||||||
|
onEnter: "startBrushSession",
|
||||||
|
on: {
|
||||||
|
MOVED_POINTER: "updateBrushSession",
|
||||||
|
STOPPED_POINTING: { do: "completeSession", to: "selecting" },
|
||||||
|
CANCELLED: { do: "cancelSession", to: "selecting" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
cancelSession(data) {
|
||||||
|
session.cancel(data)
|
||||||
|
session = undefined
|
||||||
|
},
|
||||||
|
completeSession(data) {
|
||||||
|
session.complete(data)
|
||||||
|
session = undefined
|
||||||
|
},
|
||||||
|
startBrushSession(data, { point }) {
|
||||||
|
session = new Sessions.BrushSession(data, point)
|
||||||
|
},
|
||||||
|
updateBrushSession(data, { point }) {
|
||||||
|
session.update(data, point)
|
||||||
|
},
|
||||||
zoomCamera(data, payload: { delta: number; point: number[] }) {
|
zoomCamera(data, payload: { delta: number; point: number[] }) {
|
||||||
const { camera } = data
|
const { camera } = data
|
||||||
const p0 = screenToWorld(payload.point, data)
|
const p0 = screenToWorld(payload.point, data)
|
||||||
|
@ -85,6 +79,8 @@ const state = createState({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let session: Sessions.BaseSession
|
||||||
|
|
||||||
export default state
|
export default state
|
||||||
|
|
||||||
export const useSelector = createSelectorHook(state)
|
export const useSelector = createSelectorHook(state)
|
||||||
|
|
|
@ -5,7 +5,10 @@ const { styled, global, css, theme, getCssString } = createCss({
|
||||||
...defaultThemeMap,
|
...defaultThemeMap,
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
colors: {},
|
colors: {
|
||||||
|
brushFill: "rgba(0,0,0,.1)",
|
||||||
|
brushStroke: "rgba(0,0,0,.5)",
|
||||||
|
},
|
||||||
space: {},
|
space: {},
|
||||||
fontSizes: {
|
fontSizes: {
|
||||||
0: "10px",
|
0: "10px",
|
||||||
|
|
12
types.ts
12
types.ts
|
@ -3,9 +3,10 @@ export interface Data {
|
||||||
point: number[]
|
point: number[]
|
||||||
zoom: number
|
zoom: number
|
||||||
}
|
}
|
||||||
|
brush?: Bounds
|
||||||
currentPageId: string
|
currentPageId: string
|
||||||
selectedIds: string[]
|
selectedIds: string[]
|
||||||
pointedId: string
|
pointedId?: string
|
||||||
document: {
|
document: {
|
||||||
pages: Record<string, Page>
|
pages: Record<string, Page>
|
||||||
}
|
}
|
||||||
|
@ -93,3 +94,12 @@ export type Shape =
|
||||||
| RayShape
|
| RayShape
|
||||||
| LineSegmentShape
|
| LineSegmentShape
|
||||||
| RectangleShape
|
| RectangleShape
|
||||||
|
|
||||||
|
export interface Bounds {
|
||||||
|
minX: number
|
||||||
|
minY: number
|
||||||
|
maxX: number
|
||||||
|
maxY: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
|
@ -6,6 +6,22 @@ export function screenToWorld(point: number[], data: Data) {
|
||||||
return vec.add(vec.div(point, data.camera.zoom), data.camera.point)
|
return vec.add(vec.div(point, data.camera.zoom), data.camera.point)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBoundsFromPoints(a: number[], b: number[]) {
|
||||||
|
const minX = Math.min(a[0], b[0])
|
||||||
|
const maxX = Math.max(a[0], b[0])
|
||||||
|
const minY = Math.min(a[1], b[1])
|
||||||
|
const maxY = Math.max(a[1], b[1])
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX,
|
||||||
|
maxX,
|
||||||
|
minY,
|
||||||
|
maxY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A helper for getting tangents.
|
// A helper for getting tangents.
|
||||||
export function getCircleTangentToPoint(
|
export function getCircleTangentToPoint(
|
||||||
A: number[],
|
A: number[],
|
||||||
|
@ -827,3 +843,8 @@ export async function postJsonToEndpoint(
|
||||||
|
|
||||||
return await d.json()
|
return await d.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPointerEventInfo(e: React.PointerEvent) {
|
||||||
|
const { shiftKey, ctrlKey, metaKey, altKey } = e
|
||||||
|
return { point: [e.clientX, e.clientY], shiftKey, ctrlKey, metaKey, altKey }
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue