refactor: extract index.ts into 7 focused modules
Break 1507-line index.ts into a slim 251-line entry point plus: - lib/theme.ts, lib/state.ts (shared constants + app singleton) - ui/formatters.ts, ui/panels.ts (row formatting + UI updates) - input/parser.ts, input/handlers.ts (stdin parsing + keyboard/mouse) - grid/view-switch.ts, actions/launch.ts (grid switching + PTY launch) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
492
src/input/handlers.ts
Normal file
492
src/input/handlers.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import type { KeyEvent } from "@opentui/core"
|
||||
import { app } from "../lib/state"
|
||||
import { updateAll, rebuildDisplayRows, applySortMode } from "../ui/panels"
|
||||
import { extractKeyboardInput, extractMouseEvents } from "./parser"
|
||||
import { switchToGrid } from "../grid/view-switch"
|
||||
import { doLaunch } from "../actions/launch"
|
||||
import { launchSelections } from "../actions/launcher"
|
||||
import { loadSessions } from "../data/sessions"
|
||||
import { loadBranches } from "../data/git"
|
||||
import { generateMockSessions, generateMockBranches } from "../data/mock"
|
||||
import { focusTerminalByPath, getSessionStatus, populateMockSessionStatus } from "../data/monitor"
|
||||
import { stopAllCaptures } from "../pty/capture"
|
||||
|
||||
// Shift+arrow sequences across terminal emulators
|
||||
const SHIFT_ARROWS: Record<string, "up" | "down" | "left" | "right"> = {
|
||||
"\x1b[1;2A": "up",
|
||||
"\x1b[1;2B": "down",
|
||||
"\x1b[1;2C": "right",
|
||||
"\x1b[1;2D": "left",
|
||||
"\x1b[a": "up",
|
||||
"\x1b[b": "down",
|
||||
"\x1b[c": "right",
|
||||
"\x1b[d": "left",
|
||||
}
|
||||
|
||||
export async function expandProject(projectIndex: number) {
|
||||
const project = app.projects[projectIndex]
|
||||
if (app.demoMode) {
|
||||
if (!project.sessions) {
|
||||
project.sessions = generateMockSessions(project.path)
|
||||
project.sessionCount = project.sessions.length
|
||||
}
|
||||
if (!project.branches) {
|
||||
project.branches = generateMockBranches(project.path)
|
||||
}
|
||||
populateMockSessionStatus(project)
|
||||
} else {
|
||||
const loads: Promise<void>[] = []
|
||||
if (!project.sessions) {
|
||||
loads.push(
|
||||
loadSessions(project.path).then(s => {
|
||||
project.sessions = s
|
||||
project.sessionCount = s.length
|
||||
})
|
||||
)
|
||||
}
|
||||
if (!project.branches) {
|
||||
loads.push(
|
||||
loadBranches(project.path).then(b => { project.branches = b }).catch(() => { project.branches = [] })
|
||||
)
|
||||
}
|
||||
if (loads.length > 0) await Promise.all(loads)
|
||||
}
|
||||
project.expanded = true
|
||||
rebuildDisplayRows()
|
||||
updateAll()
|
||||
}
|
||||
|
||||
export function hitTestListRow(screenRow: number): number {
|
||||
const listStartY = 2
|
||||
const relY = screenRow - listStartY + app.listBox.scrollTop
|
||||
if (relY < 0) return -1
|
||||
|
||||
let y = 0
|
||||
for (let i = 0; i < app.displayRows.length; i++) {
|
||||
const h = app.displayRows[i].type === "session" ? 3 : 1
|
||||
if (relY >= y && relY < y + h) return i
|
||||
y += h
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
export function handlePickerClick(_col: number, screenRow: number) {
|
||||
const idx = hitTestListRow(screenRow)
|
||||
if (idx < 0 || idx >= app.displayRows.length) return
|
||||
|
||||
app.cursor = idx
|
||||
const row = app.displayRows[idx]
|
||||
const project = app.projects[row.projectIndex]
|
||||
|
||||
if (row.type === "project" || row.type === "new-session") {
|
||||
const path = project.path
|
||||
if (app.selectedProjects.has(path)) app.selectedProjects.delete(path)
|
||||
else app.selectedProjects.add(path)
|
||||
} else if (row.type === "session") {
|
||||
const session = project.sessions![row.sessionIndex!]
|
||||
if (app.selectedSessions.has(session.id)) app.selectedSessions.delete(session.id)
|
||||
else app.selectedSessions.add(session.id)
|
||||
} else if (row.type === "branch") {
|
||||
const path = project.path
|
||||
if (app.selectedBranches.get(path) === row.branchName) {
|
||||
app.selectedBranches.delete(path)
|
||||
} else {
|
||||
app.selectedBranches.set(path, row.branchName!)
|
||||
}
|
||||
}
|
||||
|
||||
updateAll()
|
||||
}
|
||||
|
||||
export async function handleKeypress(key: KeyEvent) {
|
||||
try {
|
||||
const total = app.displayRows.length
|
||||
if (total === 0) return
|
||||
|
||||
switch (key.name) {
|
||||
case "up":
|
||||
if (app.cursor > 0) app.cursor--
|
||||
break
|
||||
|
||||
case "down":
|
||||
if (app.cursor < total - 1) app.cursor++
|
||||
break
|
||||
|
||||
case "pageup":
|
||||
app.cursor = Math.max(0, app.cursor - 15)
|
||||
break
|
||||
|
||||
case "pagedown":
|
||||
app.cursor = Math.min(total - 1, app.cursor + 15)
|
||||
break
|
||||
|
||||
case "home":
|
||||
app.cursor = 0
|
||||
break
|
||||
|
||||
case "end":
|
||||
app.cursor = total - 1
|
||||
break
|
||||
|
||||
case "right": {
|
||||
const row = app.displayRows[app.cursor]
|
||||
if (row.type === "project") {
|
||||
const project = app.projects[row.projectIndex]
|
||||
if (!project.expanded) {
|
||||
expandProject(row.projectIndex)
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case "left": {
|
||||
const row = app.displayRows[app.cursor]
|
||||
if (row.type === "project") {
|
||||
app.projects[row.projectIndex].expanded = false
|
||||
} else {
|
||||
app.projects[row.projectIndex].expanded = false
|
||||
const target = row.projectIndex
|
||||
rebuildDisplayRows()
|
||||
app.cursor = app.displayRows.findIndex(
|
||||
(r) => r.type === "project" && r.projectIndex === target
|
||||
)
|
||||
if (app.cursor < 0) app.cursor = 0
|
||||
}
|
||||
rebuildDisplayRows()
|
||||
if (app.cursor >= app.displayRows.length) app.cursor = app.displayRows.length - 1
|
||||
break
|
||||
}
|
||||
|
||||
case "space": {
|
||||
const row = app.displayRows[app.cursor]
|
||||
if (row.type === "project" || row.type === "new-session") {
|
||||
const path = app.projects[row.projectIndex].path
|
||||
if (app.selectedProjects.has(path)) app.selectedProjects.delete(path)
|
||||
else app.selectedProjects.add(path)
|
||||
} else if (row.type === "session") {
|
||||
const session = app.projects[row.projectIndex].sessions![row.sessionIndex!]
|
||||
if (app.selectedSessions.has(session.id)) app.selectedSessions.delete(session.id)
|
||||
else app.selectedSessions.add(session.id)
|
||||
} else if (row.type === "branch") {
|
||||
const path = app.projects[row.projectIndex].path
|
||||
if (app.selectedBranches.get(path) === row.branchName) {
|
||||
app.selectedBranches.delete(path)
|
||||
} else {
|
||||
app.selectedBranches.set(path, row.branchName!)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "f": {
|
||||
const row = app.displayRows[app.cursor]
|
||||
const project = app.projects[row.projectIndex]
|
||||
Bun.spawn(["open", project.path])
|
||||
break
|
||||
}
|
||||
|
||||
case "g": {
|
||||
const row = app.displayRows[app.cursor]
|
||||
const project = app.projects[row.projectIndex]
|
||||
if (project.activeSessions > 0) {
|
||||
const sid = row.type === "session" && project.sessions
|
||||
? project.sessions[row.sessionIndex!]?.id
|
||||
: undefined
|
||||
await focusTerminalByPath(project.path, sid)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case "a":
|
||||
for (const p of app.projects) app.selectedProjects.add(p.path)
|
||||
break
|
||||
|
||||
case "n":
|
||||
app.selectedProjects.clear()
|
||||
app.selectedSessions.clear()
|
||||
app.selectedBranches.clear()
|
||||
break
|
||||
|
||||
case "i":
|
||||
app.bottomPanelMode = app.bottomPanelMode === "preview" ? "idle" : "preview"
|
||||
app.idleCursor = 0
|
||||
break
|
||||
|
||||
case "tab":
|
||||
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) {
|
||||
if (key.shift) {
|
||||
app.idleCursor = app.idleCursor > 0 ? app.idleCursor - 1 : Math.min(app.cachedIdleSessions.length, 3) - 1
|
||||
} else {
|
||||
app.idleCursor = (app.idleCursor + 1) % Math.min(app.cachedIdleSessions.length, 3)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case "s":
|
||||
app.sortMode = (app.sortMode + 1) % app.sortLabels.length
|
||||
applySortMode()
|
||||
app.cursor = 0
|
||||
break
|
||||
|
||||
case "return": {
|
||||
const hasSelections = app.selectedProjects.size > 0 || app.selectedSessions.size > 0
|
||||
if (hasSelections) {
|
||||
doLaunch()
|
||||
break
|
||||
}
|
||||
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) {
|
||||
const focused = await focusTerminalByPath(app.cachedIdleSessions[app.idleCursor].projectPath)
|
||||
if (focused) return
|
||||
}
|
||||
const returnRow = app.displayRows[app.cursor]
|
||||
if (
|
||||
returnRow.type === "project" &&
|
||||
app.projects[returnRow.projectIndex].activeSessions > 0
|
||||
) {
|
||||
const focused = await focusTerminalByPath(app.projects[returnRow.projectIndex].path)
|
||||
if (focused) return
|
||||
}
|
||||
doLaunch()
|
||||
break
|
||||
}
|
||||
|
||||
case "o": {
|
||||
const hasOSel = app.selectedProjects.size > 0 || app.selectedSessions.size > 0
|
||||
if (!hasOSel) {
|
||||
const oRow = app.displayRows[app.cursor]
|
||||
if (oRow) app.selectedProjects.add(app.projects[oRow.projectIndex].path)
|
||||
}
|
||||
if (app.selectedProjects.size > 0 || app.selectedSessions.size > 0) {
|
||||
await launchSelections(app.projects, app.selectedProjects, app.selectedSessions, app.selectedBranches)
|
||||
app.selectedProjects.clear()
|
||||
app.selectedSessions.clear()
|
||||
app.selectedBranches.clear()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "q":
|
||||
case "escape":
|
||||
app.destroyed = true
|
||||
if (app.monitorInterval) clearInterval(app.monitorInterval)
|
||||
stopAllCaptures()
|
||||
process.stdout.write("\x1b[?1006l")
|
||||
process.stdout.write("\x1b[?1000l")
|
||||
app.renderer.destroy()
|
||||
return
|
||||
|
||||
case "t":
|
||||
if (app.directGrid && app.directGrid.paneCount > 0) {
|
||||
switchToGrid()
|
||||
return
|
||||
}
|
||||
return
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
updateAll()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function handleGridInput(rawSequence: string): Promise<boolean> {
|
||||
if (app.viewMode !== "grid" || !app.directGrid) return false
|
||||
|
||||
if (rawSequence === "\x1b" && app.directGrid.isExpanded) {
|
||||
app.directGrid.collapsePane()
|
||||
return true
|
||||
}
|
||||
|
||||
if (rawSequence === "\x1e" || rawSequence === "\x1b`") {
|
||||
switchToPicker()
|
||||
return true
|
||||
}
|
||||
|
||||
if (rawSequence === "\x0e") {
|
||||
app.directGrid.focusNext()
|
||||
return true
|
||||
}
|
||||
|
||||
if (rawSequence === "\x10") {
|
||||
app.directGrid.focusPrev()
|
||||
return true
|
||||
}
|
||||
|
||||
if (rawSequence === "\x06") {
|
||||
const pane = app.directGrid.focusedPane
|
||||
if (pane) Bun.spawn(["open", pane.session.projectPath])
|
||||
return true
|
||||
}
|
||||
|
||||
if (rawSequence === "\x17") {
|
||||
const pane = app.directGrid.focusedPane
|
||||
if (pane) {
|
||||
if (app.directGrid.isExpanded) app.directGrid.collapsePane()
|
||||
const { killSession } = await import("../pty/session-manager")
|
||||
app.directGrid.removePane(pane.session.name)
|
||||
await killSession(pane.session.name)
|
||||
if (app.directGrid.paneCount === 0) {
|
||||
switchToPicker()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (rawSequence === "\x1b[5~") {
|
||||
app.directGrid.sendScrollToFocused("up")
|
||||
return true
|
||||
}
|
||||
if (rawSequence === "\x1b[6~") {
|
||||
app.directGrid.sendScrollToFocused("down")
|
||||
return true
|
||||
}
|
||||
|
||||
app.directGrid.sendInputToFocused(rawSequence)
|
||||
return true
|
||||
}
|
||||
|
||||
export function switchToPicker() {
|
||||
app.viewMode = "picker"
|
||||
if (app.directGrid) {
|
||||
if (app.directGrid.selectMode) app.directGrid.exitSelectMode()
|
||||
if (app.directGrid.paneCount > 0) app.directGrid.stop()
|
||||
}
|
||||
app.renderer.resume()
|
||||
process.stdin.removeAllListeners("data")
|
||||
process.stdin.on("data", stdinHandler)
|
||||
process.stdout.write("\x1b[?1000h")
|
||||
process.stdout.write("\x1b[?1006h")
|
||||
if (app.mainBox) app.mainBox.visible = true
|
||||
updateAll()
|
||||
app.renderer.requestRender()
|
||||
}
|
||||
|
||||
export function stdinHandler(data: string | Buffer) {
|
||||
const str = typeof data === "string" ? data : data.toString("utf8")
|
||||
|
||||
if (app.viewMode === "grid" && app.directGrid) {
|
||||
if (app.directGrid.selectMode) {
|
||||
const keyboard = extractKeyboardInput(str)
|
||||
if (keyboard === "\x1b") {
|
||||
app.directGrid.exitSelectMode()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const mouseEvents = extractMouseEvents(str)
|
||||
for (const me of mouseEvents) {
|
||||
if (me.btn === 64) {
|
||||
app.directGrid.sendScrollToFocused("up", 3)
|
||||
continue
|
||||
}
|
||||
if (me.btn === 65) {
|
||||
app.directGrid.sendScrollToFocused("down", 3)
|
||||
continue
|
||||
}
|
||||
if (me.btn === 0 && !me.release) {
|
||||
const btn = app.directGrid.checkButtonClick(me.col, me.row)
|
||||
if (btn?.action === "max") {
|
||||
app.directGrid.expandPane(btn.paneIndex)
|
||||
} else if (btn?.action === "min") {
|
||||
app.directGrid.collapsePane()
|
||||
} else if (btn?.action === "sel") {
|
||||
app.directGrid.enterSelectMode()
|
||||
} else {
|
||||
app.directGrid.focusByClick(me.col, me.row)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let stripped = str
|
||||
for (let i = mouseEvents.length - 1; i >= 0; i--) {
|
||||
const me = mouseEvents[i]
|
||||
stripped = stripped.slice(0, me.start) + stripped.slice(me.end)
|
||||
}
|
||||
|
||||
const keyboard = extractKeyboardInput(stripped)
|
||||
if (keyboard) {
|
||||
const dir = SHIFT_ARROWS[keyboard]
|
||||
if (dir) {
|
||||
app.directGrid.focusByDirection(dir)
|
||||
} else {
|
||||
handleGridInput(keyboard)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Picker mode
|
||||
const pickerMouse = extractMouseEvents(str)
|
||||
for (const me of pickerMouse) {
|
||||
if (me.btn === 0 && !me.release) handlePickerClick(me.col, me.row)
|
||||
if (me.btn === 64) { if (app.cursor > 0) { app.cursor--; updateAll() } }
|
||||
if (me.btn === 65) { if (app.cursor < app.displayRows.length - 1) { app.cursor++; updateAll() } }
|
||||
}
|
||||
|
||||
const keyboard = extractKeyboardInput(str)
|
||||
if (!keyboard) return
|
||||
|
||||
const keyMap: Record<string, { name: string; shift?: boolean; ctrl?: boolean }> = {
|
||||
"\x1b[A": { name: "up" },
|
||||
"\x1b[B": { name: "down" },
|
||||
"\x1b[C": { name: "right" },
|
||||
"\x1b[D": { name: "left" },
|
||||
"\x1b[5~": { name: "pageup" },
|
||||
"\x1b[6~": { name: "pagedown" },
|
||||
"\x1b[H": { name: "home" },
|
||||
"\x1b[F": { name: "end" },
|
||||
"\x1bOH": { name: "home" },
|
||||
"\x1bOF": { name: "end" },
|
||||
"\x1b[Z": { name: "tab", shift: true },
|
||||
"\x1b[1;2A": { name: "up", shift: true },
|
||||
"\x1b[1;2B": { name: "down", shift: true },
|
||||
"\x1b[1;2C": { name: "right", shift: true },
|
||||
"\x1b[1;2D": { name: "left", shift: true },
|
||||
"\x09": { name: "tab" },
|
||||
"\x0d": { name: "return" },
|
||||
"\x1b": { name: "escape" },
|
||||
" ": { name: "space" },
|
||||
}
|
||||
|
||||
let ki = 0
|
||||
while (ki < keyboard.length) {
|
||||
let matched = false
|
||||
for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) {
|
||||
const seq = keyboard.slice(ki, ki + len)
|
||||
const mapped = keyMap[seq]
|
||||
if (mapped) {
|
||||
const syntheticKey = {
|
||||
name: mapped.name,
|
||||
shift: mapped.shift || false,
|
||||
ctrl: mapped.ctrl || false,
|
||||
meta: false,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as KeyEvent
|
||||
handleKeypress(syntheticKey)
|
||||
ki += len
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
const ch = keyboard[ki]
|
||||
const code = ch.charCodeAt(0)
|
||||
if (code >= 0x21 && code <= 0x7e) {
|
||||
const syntheticKey = {
|
||||
name: ch,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as KeyEvent
|
||||
handleKeypress(syntheticKey)
|
||||
}
|
||||
ki++
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/input/parser.ts
Normal file
102
src/input/parser.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Extract only safe keyboard input from stdin data.
|
||||
// WHITELIST approach: only recognized keyboard sequences pass through.
|
||||
// Everything else (mouse events, terminal responses, OSC, DCS, etc.) is dropped.
|
||||
export function extractKeyboardInput(data: string): string {
|
||||
let keyboard = ""
|
||||
let i = 0
|
||||
|
||||
while (i < data.length) {
|
||||
const c = data.charCodeAt(i)
|
||||
|
||||
// ESC sequences
|
||||
if (c === 0x1b) {
|
||||
if (i + 1 >= data.length) { keyboard += "\x1b"; i++; continue } // lone ESC = Escape key
|
||||
|
||||
const next = data[i + 1]
|
||||
|
||||
// OSC: \x1b] ... (terminated by BEL \x07 or ST \x1b\\) — drop entirely
|
||||
if (next === "]") {
|
||||
let j = i + 2
|
||||
while (j < data.length) {
|
||||
if (data[j] === "\x07") { j++; break }
|
||||
if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break }
|
||||
j++
|
||||
}
|
||||
i = j; continue
|
||||
}
|
||||
|
||||
// DCS: \x1bP ... ST | APC: \x1b_ ... ST | PM: \x1b^ ... ST — drop entirely
|
||||
if (next === "P" || next === "_" || next === "^") {
|
||||
let j = i + 2
|
||||
while (j < data.length) {
|
||||
if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break }
|
||||
j++
|
||||
}
|
||||
i = j; continue
|
||||
}
|
||||
|
||||
// CSI: \x1b[
|
||||
if (next === "[") {
|
||||
let j = i + 2
|
||||
// Consume parameter bytes (0x30-0x3F: digits, ;, <, =, >, ?)
|
||||
while (j < data.length && data.charCodeAt(j) >= 0x30 && data.charCodeAt(j) <= 0x3F) j++
|
||||
// Consume intermediate bytes (0x20-0x2F)
|
||||
while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) <= 0x2F) j++
|
||||
// Final byte (0x40-0x7E)
|
||||
if (j < data.length && data.charCodeAt(j) >= 0x40 && data.charCodeAt(j) <= 0x7E) {
|
||||
const final = data[j]
|
||||
// Legacy X10 mouse: \x1b[M followed by 3 raw bytes (btn+32, col+32, row+32)
|
||||
if (final === "M" && j === i + 2) {
|
||||
i = Math.min(j + 4, data.length); continue
|
||||
}
|
||||
// ONLY keep: arrows (A-D), Home (H), End (F), shift-tab (Z), function keys (~)
|
||||
if ("ABCDHFZ~".includes(final)) {
|
||||
keyboard += data.slice(i, j + 1)
|
||||
}
|
||||
i = j + 1; continue
|
||||
}
|
||||
// Incomplete/malformed CSI — drop
|
||||
i = j; continue
|
||||
}
|
||||
|
||||
// SS3: \x1bO + letter (F1-F4, keypad)
|
||||
if (next === "O" && i + 2 < data.length) {
|
||||
keyboard += data.slice(i, i + 3)
|
||||
i += 3; continue
|
||||
}
|
||||
|
||||
// \x1b` (ctrl+backtick) — keep as keyboard shortcut
|
||||
if (next === "`") {
|
||||
keyboard += "\x1b`"
|
||||
i += 2; continue
|
||||
}
|
||||
|
||||
// Any other \x1b+char — drop (unknown escape sequence)
|
||||
i += 2; continue
|
||||
}
|
||||
|
||||
// Regular character: printable ASCII, control chars, UTF-8 — keep
|
||||
keyboard += data[i]
|
||||
i++
|
||||
}
|
||||
|
||||
return keyboard
|
||||
}
|
||||
|
||||
// Parse SGR mouse events from raw data.
|
||||
export function extractMouseEvents(data: string): { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] {
|
||||
const events: { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] = []
|
||||
const re = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g
|
||||
let m
|
||||
while ((m = re.exec(data)) !== null) {
|
||||
events.push({
|
||||
btn: parseInt(m[1]),
|
||||
col: parseInt(m[2]),
|
||||
row: parseInt(m[3]),
|
||||
release: m[4] === "m",
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
Reference in New Issue
Block a user