fix(keybinding): stop stealing chords on Windows, release mouse holds, keep cleared actions
This commit is contained in:
parent
115e8488ac
commit
1a4c39cb94
8 changed files with 927 additions and 148 deletions
185
packages/core/__tests__/keybinding-redteam-r2-16.test.ts
Normal file
185
packages/core/__tests__/keybinding-redteam-r2-16.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// packages/core/__tests__/keybinding-redteam-r2-16.test.ts
|
||||
// 레드팀 r2-16 회귀 테스트.
|
||||
// - 저장 코덱(decodeKeyBindingMap): 사용자가 비운 액션([])을 기본값으로 되살리지 않는다.
|
||||
// - 런타임(ChordStateMachine): 마우스 버튼 누름 중 수정자를 더 눌러도 mouseup 이 release 한다.
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ChordStateMachine, isChordComponentOf } from '../src/keybinding-runtime'
|
||||
import type { ChordClock, ChordTriggerEvent } from '../src/keybinding-runtime'
|
||||
import {
|
||||
KEYBINDING_ACTIONS,
|
||||
MouseButton,
|
||||
VK,
|
||||
bindingKey,
|
||||
createDefaultBindingMap,
|
||||
decodeKeyBindingMap,
|
||||
kb
|
||||
} from '../src/keybinding'
|
||||
import type { KeyBinding } from '../src/keybinding'
|
||||
|
||||
const F8 = 0x77
|
||||
|
||||
function key(code: number, mods: Partial<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>> = {}): KeyBinding {
|
||||
return kb(code, mods)
|
||||
}
|
||||
|
||||
function mouse(code: number, mods: Partial<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>> = {}): KeyBinding {
|
||||
return { ...kb(code, mods), device: 'mouse' }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// decodeKeyBindingMap
|
||||
// ============================================================
|
||||
|
||||
describe('decodeKeyBindingMap', () => {
|
||||
it('명시적 빈 목록은 빈 목록 그대로 보존한다 (기본값으로 되살리지 않는다)', () => {
|
||||
const decoded = decodeKeyBindingMap({ ...createDefaultBindingMap(), 'history-popup': [] })
|
||||
expect(decoded.map['history-popup']).toEqual([])
|
||||
expect(decoded.repairedActions).toEqual([])
|
||||
expect(decoded.needsRewrite).toBe(false)
|
||||
})
|
||||
|
||||
it('빈 목록은 decode → 저장 → decode 왕복에서도 빈 목록이다', () => {
|
||||
const first = decodeKeyBindingMap({ ...createDefaultBindingMap(), caption: [] })
|
||||
const roundTrip = decodeKeyBindingMap(JSON.parse(JSON.stringify(first.map)))
|
||||
expect(roundTrip.map.caption).toEqual([])
|
||||
expect(roundTrip.map).toEqual(first.map)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['누락', undefined],
|
||||
['배열 아님', 'garbage'],
|
||||
['객체', { device: 'keyboard', code: 65 }],
|
||||
['비어 있지 않지만 전부 손상', [{ device: 'keyboard', code: 65 }, null, 7]]
|
||||
])('%s 인 액션만 기본값으로 복구하고 repairedActions 에 넣는다', (_label, stored) => {
|
||||
const defaults = createDefaultBindingMap()
|
||||
const decoded = decodeKeyBindingMap({ ...defaults, command: stored })
|
||||
expect(decoded.map.command).toEqual(defaults.command)
|
||||
expect(decoded.repairedActions).toEqual(['command'])
|
||||
expect(decoded.needsRewrite).toBe(true)
|
||||
})
|
||||
|
||||
it('레코드가 아니면 모든 액션을 복구한다', () => {
|
||||
for (const value of [null, undefined, 'x', 7, [key(F8)]]) {
|
||||
const decoded = decodeKeyBindingMap(value)
|
||||
expect(decoded.map).toEqual(createDefaultBindingMap())
|
||||
expect(decoded.repairedActions.sort()).toEqual(KEYBINDING_ACTIONS.map((a) => a.id).sort())
|
||||
expect(decoded.needsRewrite).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('일부 손상 항목은 버리고 유효 항목을 유지한다 — 복구가 아니라 정규화다', () => {
|
||||
const decoded = decodeKeyBindingMap({ ...createDefaultBindingMap(), dictation: [key(F8), 'junk'] })
|
||||
expect(decoded.map.dictation).toEqual([key(F8)])
|
||||
expect(decoded.repairedActions).toEqual([])
|
||||
expect(decoded.needsRewrite).toBe(true)
|
||||
})
|
||||
|
||||
it('정규화·중복 제거가 필요하면 needsRewrite, 이미 정본 모양이면 다시 쓰지 않는다', () => {
|
||||
const unnormalized = decodeKeyBindingMap({
|
||||
...createDefaultBindingMap(),
|
||||
dictation: [key(VK.AltRight, { alt: true, ctrl: true })]
|
||||
})
|
||||
expect(unnormalized.map.dictation).toEqual([key(VK.AltRight, { ctrl: true })])
|
||||
expect(unnormalized.needsRewrite).toBe(true)
|
||||
|
||||
const duplicated = decodeKeyBindingMap({ ...createDefaultBindingMap(), dictation: [key(F8), key(F8)] })
|
||||
expect(duplicated.map.dictation).toEqual([key(F8)])
|
||||
expect(duplicated.needsRewrite).toBe(true)
|
||||
|
||||
expect(decodeKeyBindingMap(createDefaultBindingMap()).needsRewrite).toBe(false)
|
||||
})
|
||||
|
||||
it('알 수 없는 액션 키는 버리고 모든 액션 키를 갖는다', () => {
|
||||
const decoded = decodeKeyBindingMap({ ...createDefaultBindingMap(), 'not-an-action': [key(F8)] })
|
||||
expect(Object.keys(decoded.map).sort()).toEqual(KEYBINDING_ACTIONS.map((a) => a.id).sort())
|
||||
expect(Object.keys(decoded.map)).not.toContain('not-an-action')
|
||||
})
|
||||
|
||||
it('반환 맵은 기본값 정의와 참조를 공유하지 않는다', () => {
|
||||
const decoded = decodeKeyBindingMap(null)
|
||||
decoded.map.dictation[0]!.ctrl = true
|
||||
expect(createDefaultBindingMap().dictation[0]!.ctrl).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 마우스 hold 바인딩 release
|
||||
// ============================================================
|
||||
|
||||
const clock: ChordClock = {
|
||||
now: () => 1_000,
|
||||
schedule: () => () => undefined
|
||||
}
|
||||
|
||||
function labels(events: ChordTriggerEvent[]): string[] {
|
||||
return events.map((e) => `${e.actionId}:${e.type}`)
|
||||
}
|
||||
|
||||
describe('ChordStateMachine — 마우스 버튼 누름 중 수정자 추가', () => {
|
||||
it('X1 누름 → Alt 누름 → X1 놓음(mouseup 은 X1+Alt) 이면 release 한다', () => {
|
||||
const events: ChordTriggerEvent[] = []
|
||||
const machine = new ChordStateMachine({ clock, onTrigger: (e) => events.push(e) })
|
||||
const x1 = mouse(MouseButton.Back)
|
||||
machine.setBindings([{ actionId: 'dictation', holdMode: true, doublePress: false, binding: x1 }])
|
||||
|
||||
machine.keyDown(bindingKey(x1))
|
||||
// Alt 키 누름/놓음 자체는 X1 바인딩의 구성 요소가 아니다.
|
||||
machine.keyUp(bindingKey(key(VK.AltLeft)), VK.AltLeft)
|
||||
expect(labels(events)).toEqual(['dictation:pressed'])
|
||||
|
||||
machine.keyUp(bindingKey(mouse(MouseButton.Back, { alt: true })), {
|
||||
device: 'mouse',
|
||||
code: MouseButton.Back
|
||||
})
|
||||
expect(labels(events)).toEqual(['dictation:pressed', 'dictation:released'])
|
||||
|
||||
// 눌림 상태가 남지 않았으므로 다음 누름이 auto-repeat 로 삼켜지지 않는다.
|
||||
machine.keyDown(bindingKey(x1))
|
||||
expect(labels(events)).toEqual(['dictation:pressed', 'dictation:released', 'dictation:pressed'])
|
||||
})
|
||||
|
||||
it('다른 버튼의 mouseup 은 release 하지 않는다', () => {
|
||||
const events: ChordTriggerEvent[] = []
|
||||
const machine = new ChordStateMachine({ clock, onTrigger: (e) => events.push(e) })
|
||||
const x1 = mouse(MouseButton.Back)
|
||||
machine.setBindings([{ actionId: 'dictation', holdMode: true, doublePress: false, binding: x1 }])
|
||||
|
||||
machine.keyDown(bindingKey(x1))
|
||||
machine.keyUp(bindingKey(mouse(MouseButton.Forward, { alt: true })), {
|
||||
device: 'mouse',
|
||||
code: MouseButton.Forward
|
||||
})
|
||||
expect(labels(events)).toEqual(['dictation:pressed'])
|
||||
})
|
||||
|
||||
it('숫자 component 는 키보드 VK 로 해석한다 (옛 호출 형태 호환)', () => {
|
||||
const events: ChordTriggerEvent[] = []
|
||||
const machine = new ChordStateMachine({ clock, onTrigger: (e) => events.push(e) })
|
||||
const ctrlShiftV = key(0x56, { ctrl: true, shift: true })
|
||||
machine.setBindings([{ actionId: 'history-popup', holdMode: false, doublePress: false, binding: ctrlShiftV }])
|
||||
machine.keyDown(bindingKey(ctrlShiftV))
|
||||
machine.keyUp(bindingKey(key(VK.ShiftLeft, { ctrl: true })), VK.ShiftLeft)
|
||||
expect(labels(events)).toEqual(['history-popup:pressed', 'history-popup:released'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isChordComponentOf', () => {
|
||||
it('같은 장치·같은 코드면 수정자와 무관하게 구성 요소다', () => {
|
||||
expect(isChordComponentOf({ device: 'mouse', code: MouseButton.Back }, mouse(MouseButton.Back))).toBe(true)
|
||||
expect(
|
||||
isChordComponentOf({ device: 'mouse', code: MouseButton.Back }, mouse(MouseButton.Back, { ctrl: true }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('마우스 버튼 코드는 같은 숫자의 키보드 키와 섞이지 않는다', () => {
|
||||
// MouseButton.Back 과 같은 숫자의 VK 키보드 바인딩
|
||||
expect(isChordComponentOf({ device: 'mouse', code: MouseButton.Back }, key(MouseButton.Back))).toBe(false)
|
||||
expect(isChordComponentOf({ device: 'keyboard', code: MouseButton.Back }, mouse(MouseButton.Back))).toBe(false)
|
||||
})
|
||||
|
||||
it('키보드 수정자 키는 그 수정자를 켠 바인딩(마우스 포함)의 구성 요소다', () => {
|
||||
expect(isChordComponentOf({ device: 'keyboard', code: VK.CtrlLeft }, mouse(MouseButton.Back, { ctrl: true }))).toBe(true)
|
||||
expect(isChordComponentOf({ device: 'keyboard', code: VK.AltLeft }, mouse(MouseButton.Back))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -10,14 +10,14 @@
|
|||
// 어댑터(데스크톱 KeyBindingService)의 책임:
|
||||
// - 장치 이벤트 → bindingKey / VK 변환
|
||||
// - 관측 불가능한 키 걸러내기(등록 전에)
|
||||
// - macOS beep 차단용 accelerator, 설정 로딩
|
||||
// - macOS beep 차단용 accelerator(플랫폼 · 기능 게이트 포함), 설정 로딩
|
||||
//
|
||||
// 런타임 상태는 액션 id 가 아니라 bindingKey() 로 키잉한다 —
|
||||
// 한 액션에 여러 바인딩이 붙고, 여러 액션이 한 바인딩을 공유하기 때문이다.
|
||||
|
||||
import { TIMING } from './constants'
|
||||
import { bindingKey, isModifierKeyCode, normalizeBinding } from './keybinding'
|
||||
import type { KeyBinding, KeyBindingActionId } from './keybinding'
|
||||
import type { BindingDevice, KeyBinding, KeyBindingActionId } from './keybinding'
|
||||
|
||||
// ============================================================
|
||||
// 공개 타입 · 포트
|
||||
|
|
@ -46,6 +46,15 @@ export interface ChordBindingEntry {
|
|||
binding: KeyBinding
|
||||
}
|
||||
|
||||
/**
|
||||
* 놓인 물리 키/버튼. keyUp 이 정확 매칭에 실패했을 때 "눌림 상태 바인딩의 구성 요소가
|
||||
* 놓였는가" 를 판정하는 데 쓴다. 키보드는 Windows VK, 마우스는 MouseButton 코드다.
|
||||
*/
|
||||
export interface ChordReleasedComponent {
|
||||
device: BindingDevice
|
||||
code: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간 포트. 실제 구현은 Date.now / setTimeout, 테스트는 가짜 타이머를 넣는다.
|
||||
* schedule 은 취소 함수를 돌려준다 — 타이머 핸들 타입을 core 가 알 필요가 없다.
|
||||
|
|
@ -118,16 +127,33 @@ export function isAltGrShapedBinding(binding: KeyBinding): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* 놓인 키(VK)가 바인딩의 주 키이거나 켜진 수정자의 구성 키인가.
|
||||
* 마우스 바인딩(Ctrl+뒤로 등)도 수정자를 먼저 놓으면 release 되어야 한다 —
|
||||
* 그 뒤의 mouseup 은 수정자 없이 들어와 정확 매칭이 실패하기 때문이다.
|
||||
* 놓인 키/버튼이 바인딩의 주 키이거나 켜진 수정자의 구성 키인가.
|
||||
*
|
||||
* - 주 키: 장치와 코드가 같으면 수정자 플래그와 무관하게 구성 요소다. 마우스 X1 을 누른 채
|
||||
* Alt 를 더 누르고 버튼을 놓으면 mouseup 이 `X1+Alt` 로 들어와 정확 매칭이 실패하는데,
|
||||
* 이때도 X1 누름은 놓여야 한다(아니면 hold-to-talk 가 계속 녹음한다).
|
||||
* - 수정자: 키보드 수정자 키만 해당한다. 마우스 바인딩(Ctrl+뒤로 등)도 수정자를 먼저 놓으면
|
||||
* release 되어야 한다 — 그 뒤의 mouseup 은 수정자 없이 들어와 정확 매칭이 실패하기 때문이다.
|
||||
*/
|
||||
function isComponentOf(vk: number, binding: KeyBinding): boolean {
|
||||
if (binding.device === 'keyboard' && binding.code === vk) return true
|
||||
const flag = modifierFlagOfKeyCode(vk)
|
||||
export function isChordComponentOf(
|
||||
component: ChordReleasedComponent,
|
||||
binding: KeyBinding
|
||||
): boolean {
|
||||
if (binding.device === component.device && binding.code === component.code) return true
|
||||
if (component.device !== 'keyboard') return false
|
||||
const flag = modifierFlagOfKeyCode(component.code)
|
||||
return flag !== null && binding[flag]
|
||||
}
|
||||
|
||||
/** keyUp 의 두 번째 인자 — 숫자는 키보드 VK(옛 호출 형태)다. */
|
||||
function toReleasedComponent(
|
||||
component: number | ChordReleasedComponent | null
|
||||
): ChordReleasedComponent | null {
|
||||
if (component === null) return null
|
||||
if (typeof component === 'number') return { device: 'keyboard', code: component }
|
||||
return component
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 상태 머신
|
||||
// ============================================================
|
||||
|
|
@ -297,27 +323,35 @@ export class ChordStateMachine {
|
|||
/**
|
||||
* 키/버튼 놓임.
|
||||
*
|
||||
* eventKey 가 눌림 상태와 정확히 맞으면 그 바인딩을 놓는다. 아니면 componentVk(놓인
|
||||
* 키의 VK)가 눌림 상태 바인딩의 주 키/수정자인지 본다 — Alt+1 에서 수정자를 먼저
|
||||
* 놓아도 release 를 놓치지 않기 위해서다. mouseup 은 componentVk 를 null 로 넘겨
|
||||
* 정확 매칭만 한다(마우스 버튼은 키보드 바인딩의 구성 키가 아니다).
|
||||
* eventKey 가 눌림 상태와 정확히 맞으면 그 바인딩을 놓는다. 아니면 component(놓인
|
||||
* 키/버튼)가 눌림 상태 바인딩의 주 키/수정자인지 본다 — Alt+1 에서 수정자를 먼저
|
||||
* 놓거나, 마우스 버튼을 누른 채 수정자를 더 눌러도 release 를 놓치지 않기 위해서다.
|
||||
*
|
||||
* component 가 숫자면 키보드 VK 다(옛 호출 형태). null 이면 정확 매칭만 한다.
|
||||
* 마우스 버튼은 물리적으로 하나이므로 그 버튼을 쓰는 눌림 바인딩을 모두 놓는다.
|
||||
*/
|
||||
keyUp(eventKey: string | null, componentVk: number | null): void {
|
||||
keyUp(
|
||||
eventKey: string | null,
|
||||
component: number | ChordReleasedComponent | null
|
||||
): void {
|
||||
if (eventKey !== null && this._isDown.has(eventKey)) {
|
||||
this._release(eventKey)
|
||||
return
|
||||
}
|
||||
if (componentVk === null) return
|
||||
const released = toReleasedComponent(component)
|
||||
if (released === null) return
|
||||
|
||||
const matches: string[] = []
|
||||
for (const [key, entries] of this._byKey) {
|
||||
if (!this._isDown.has(key)) continue
|
||||
const first = entries[0]
|
||||
if (first === undefined) continue
|
||||
if (isComponentOf(componentVk, first.binding)) {
|
||||
this._release(key)
|
||||
return
|
||||
if (isChordComponentOf(released, first.binding)) {
|
||||
matches.push(key)
|
||||
if (released.device === 'keyboard') break
|
||||
}
|
||||
}
|
||||
for (const key of matches) this._release(key)
|
||||
}
|
||||
|
||||
// ── 내부 ───────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1248,7 +1248,13 @@ export function parseBindingList(value: unknown): KeyBinding[] {
|
|||
return out
|
||||
}
|
||||
|
||||
/** 저장된 전체 맵을 복원한다. 누락·손상 액션은 기본값으로 채운다. */
|
||||
/**
|
||||
* 저장된 전체 맵을 복원한다 (옛 의미). 누락·손상 액션 **과 명시적 빈 목록** 을 기본값으로 채운다.
|
||||
*
|
||||
* @deprecated 저장소를 읽는 경로는 `decodeKeyBindingMap` 을 쓴다. 이 함수는 사용자가 지운
|
||||
* 액션(`[]`)을 기본값으로 되살리므로, 저장값을 읽고 다시 쓰는 곳에서 쓰면 지운 단축키가
|
||||
* 되돌아온다. 옛 호출부 호환을 위해서만 남긴다.
|
||||
*/
|
||||
export function parseBindingMap(value: unknown): KeyBindingMap {
|
||||
const defaults = createDefaultBindingMap()
|
||||
if (!isRecord(value)) return defaults
|
||||
|
|
@ -1260,3 +1266,85 @@ export function parseBindingMap(value: unknown): KeyBindingMap {
|
|||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 저장 코덱 — 저장된 keyBindings 를 읽는 단일 정본
|
||||
// ============================================================
|
||||
|
||||
/** 액션 하나의 저장값을 해석한 결과 */
|
||||
type StoredListState =
|
||||
/** 저장값 그대로 쓸 수 있다 */
|
||||
| 'intact'
|
||||
/** 유효 항목은 있지만 손상·중복 항목을 버렸거나 정규화로 모양이 바뀌었다 */
|
||||
| 'normalized'
|
||||
/** 누락·배열 아님·전부 손상 — 기본값으로 복구했다 */
|
||||
| 'repaired'
|
||||
|
||||
function sameStoredShape(stored: KeyBinding, decoded: KeyBinding): boolean {
|
||||
return (
|
||||
stored.device === decoded.device &&
|
||||
stored.code === decoded.code &&
|
||||
stored.ctrl === decoded.ctrl &&
|
||||
stored.alt === decoded.alt &&
|
||||
stored.shift === decoded.shift &&
|
||||
stored.meta === decoded.meta
|
||||
)
|
||||
}
|
||||
|
||||
function decodeBindingList(
|
||||
value: unknown,
|
||||
fallback: readonly KeyBinding[]
|
||||
): { bindings: KeyBinding[]; state: StoredListState } {
|
||||
const repaired = (): { bindings: KeyBinding[]; state: StoredListState } => ({
|
||||
bindings: fallback.map((b) => ({ ...b })),
|
||||
state: 'repaired'
|
||||
})
|
||||
if (!Array.isArray(value)) return repaired()
|
||||
// 명시적 빈 목록 = 사용자가 이 액션의 단축키를 모두 지웠다. 기본값으로 되살리지 않는다.
|
||||
if (value.length === 0) return { bindings: [], state: 'intact' }
|
||||
|
||||
const bindings = parseBindingList(value)
|
||||
// 비어 있지 않았는데 쓸 만한 항목이 하나도 없다 = 손상. 기본값으로 복구한다.
|
||||
if (bindings.length === 0) return repaired()
|
||||
|
||||
const intact =
|
||||
bindings.length === value.length &&
|
||||
bindings.every((binding, index) => {
|
||||
const stored: unknown = value[index]
|
||||
return isKeyBinding(stored) && sameStoredShape(stored, binding)
|
||||
})
|
||||
return { bindings, state: intact ? 'intact' : 'normalized' }
|
||||
}
|
||||
|
||||
/** `decodeKeyBindingMap` 결과 */
|
||||
export interface DecodedKeyBindingMap {
|
||||
/** 모든 액션 키를 가진 정규화된 맵. 명시적 빈 목록은 빈 목록 그대로다. */
|
||||
map: KeyBindingMap
|
||||
/** 저장값이 없거나(새 액션 포함) 손상되어 기본값으로 채운 액션 */
|
||||
repairedActions: KeyBindingActionId[]
|
||||
/** 결과가 저장된 모양과 달라 다시 써야 하는지 (복구 · 손상 항목 제거 · 정규화) */
|
||||
needsRewrite: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 keyBindings 를 엄격하게 해석한다 — 저장소를 읽는 모든 경로(설정 로딩 · IPC ·
|
||||
* 마이그레이션 · 후킹 서비스)의 단일 정본.
|
||||
*
|
||||
* - 명시적 빈 목록(`[]`)은 "이 액션은 단축키 없음" 으로 보존한다.
|
||||
* - 누락 · 배열 아님 · 비어 있지 않은데 유효 항목이 없는 액션만 기본값으로 복구한다.
|
||||
* - 유효 항목은 정규화하고 중복을 없앤다. 알 수 없는 액션 키는 버린다.
|
||||
*/
|
||||
export function decodeKeyBindingMap(value: unknown): DecodedKeyBindingMap {
|
||||
const record = isRecord(value) && !Array.isArray(value) ? value : null
|
||||
const map = {} as KeyBindingMap
|
||||
const repairedActions: KeyBindingActionId[] = []
|
||||
let needsRewrite = record === null
|
||||
|
||||
for (const action of KEYBINDING_ACTIONS) {
|
||||
const decoded = decodeBindingList(record?.[action.id], action.defaultBindings)
|
||||
map[action.id] = decoded.bindings
|
||||
if (decoded.state === 'repaired') repairedActions.push(action.id)
|
||||
if (decoded.state !== 'intact') needsRewrite = true
|
||||
}
|
||||
return { map, repairedActions, needsRewrite }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue