From f74bc284bd09b843d6211b3e374c3ea5db9dc417 Mon Sep 17 00:00:00 2001 From: Hyunwoo Park Date: Mon, 11 May 2026 06:19:51 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Cmd+Backspace=20=EC=A4=84=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=EA=B9=8C=EC=A7=80=20=EC=82=AD=EC=A0=9C,=20Al?= =?UTF-8?q?t+Backspace=20=EB=8B=A8=EC=96=B4=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS 표준 키보드 단축키 구현: - Cmd(Meta)+Backspace: 커서에서 줄 시작까지 삭제 - Alt(Option)+Backspace: 커서에서 이전 단어 경계까지 삭제 Windows/Linux에서도 Ctrl+Backspace (단어 삭제) 동작 지원. Ref #260 --- .../src/engine/input-handler-keyboard.ts | 82 +++++++++++++++++-- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/rhwp-studio/src/engine/input-handler-keyboard.ts b/rhwp-studio/src/engine/input-handler-keyboard.ts index bfb1a4c44..282d5699e 100644 --- a/rhwp-studio/src/engine/input-handler-keyboard.ts +++ b/rhwp-studio/src/engine/input-handler-keyboard.ts @@ -680,18 +680,26 @@ export function onKeyDown(this: any, e: KeyboardEvent): void { } // Alt 조합 단축키 처리 - if (e.altKey && this.dispatcher) { + if (e.altKey) { + // Alt+Backspace → 단어 단위 삭제 (macOS Option+Delete) + if (e.key === 'Backspace' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + deleteWordBackward.call(this); + return; + } // Alt+V → Chord 대기 (보기 메뉴 단축키, 한컴 Alt+V,T 계승) if ((e.key === 'v' || e.key === 'V' || e.key === 'ㅍ') && !e.shiftKey && !e.ctrlKey) { e.preventDefault(); this._pendingChordV = true; return; } - const cmdId = matchShortcut(e, defaultShortcuts); - if (cmdId) { - e.preventDefault(); - this.dispatcher.dispatch(cmdId); - return; + if (this.dispatcher) { + const cmdId = matchShortcut(e, defaultShortcuts); + if (cmdId) { + e.preventDefault(); + this.dispatcher.dispatch(cmdId); + return; + } } } @@ -892,8 +900,17 @@ export function handleCtrlKey(this: any, e: KeyboardEvent): void { return; } - // 커맨드 시스템에 없는 직접 처리 (Ctrl+Home/End 등 커서 이동) + // 커맨드 시스템에 없는 직접 처리 (Ctrl+Home/End, Ctrl+Backspace 등) switch (e.key.toLowerCase()) { + case 'backspace': { + e.preventDefault(); + if (e.altKey) { + deleteWordBackward.call(this); + } else { + deleteToLineStart.call(this); + } + break; + } case 'home': { e.preventDefault(); if (e.shiftKey) { @@ -930,6 +947,57 @@ export function handleSelectAll(this: any): void { this.updateCaret(); } +/** Cmd/Ctrl+Backspace: 현재 위치에서 줄 시작까지 삭제 */ +function deleteToLineStart(this: any): void { + if (this.cursor.hasSelection()) { + this.deleteSelection(); + return; + } + const pos = this.cursor.getPosition(); + this.cursor.setAnchor(); + this.cursor.moveToLineStart(); + if (this.cursor.hasSelection()) { + this.deleteSelection(); + } +} + +/** Alt/Option+Backspace: 이전 단어 경계까지 삭제 */ +function deleteWordBackward(this: any): void { + if (this.cursor.hasSelection()) { + this.deleteSelection(); + return; + } + const pos = this.cursor.getPosition(); + const { sectionIndex: sec, paragraphIndex: para, charOffset } = pos; + if (charOffset === 0) { + // 문단 시작 → 일반 Backspace와 동일 (이전 문단과 병합) + this.handleBackspace(pos, this.cursor.isInCell()); + return; + } + const wordStart = findWordBoundaryBackward(this.wasm, sec, para, charOffset); + if (wordStart < charOffset) { + this.cursor.setAnchor(); + this.cursor.moveTo({ ...pos, charOffset: wordStart }); + this.deleteSelection(); + } +} + +/** 문단 텍스트에서 이전 단어 경계를 찾는다 */ +function findWordBoundaryBackward(wasm: WasmBridge, sec: number, para: number, offset: number): number { + if (offset <= 0) return 0; + const text = wasm.getTextRange(sec, para, 0, offset); + let i = text.length - 1; + // 1) 커서 직전의 공백/구두점 건너뛰기 + while (i >= 0 && isWordSeparator(text[i])) i--; + // 2) 단어 문자 건너뛰기 + while (i >= 0 && !isWordSeparator(text[i])) i--; + return i + 1; +} + +function isWordSeparator(ch: string): boolean { + return /[\s .,;:!?'"()[\]{}<>\/\\|@#$%^&*~`+=\-_]/.test(ch); +} + export function onCopy(this: any, e: ClipboardEvent): void { if (!this.active) return; From 3f81cfde64ddf3ff1755161df27bf85d02b6918d Mon Sep 17 00:00:00 2001 From: Hyunwoo Park Date: Mon, 11 May 2026 07:59:35 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20Copilot=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=E2=80=94=20Ctrl/Cmd=20=EB=B6=84=EB=A6=AC,?= =?UTF-8?q?=20=EC=85=80=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8,=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20=EB=B3=80=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ctrl+Backspace = 단어 삭제 (Win/Linux), Cmd+Backspace = 줄 시작까지 삭제 (macOS) → metaKey/ctrlKey 구분하여 올바른 동작 매핑 - deleteWordBackward()가 표 셀 내부에서도 getTextInCell()로 텍스트 조회 - deleteToLineStart()의 미사용 pos 변수 제거 --- .../src/engine/input-handler-keyboard.ts | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/rhwp-studio/src/engine/input-handler-keyboard.ts b/rhwp-studio/src/engine/input-handler-keyboard.ts index 282d5699e..b119b65d1 100644 --- a/rhwp-studio/src/engine/input-handler-keyboard.ts +++ b/rhwp-studio/src/engine/input-handler-keyboard.ts @@ -904,10 +904,10 @@ export function handleCtrlKey(this: any, e: KeyboardEvent): void { switch (e.key.toLowerCase()) { case 'backspace': { e.preventDefault(); - if (e.altKey) { - deleteWordBackward.call(this); - } else { + if (e.metaKey) { deleteToLineStart.call(this); + } else { + deleteWordBackward.call(this); } break; } @@ -947,13 +947,12 @@ export function handleSelectAll(this: any): void { this.updateCaret(); } -/** Cmd/Ctrl+Backspace: 현재 위치에서 줄 시작까지 삭제 */ +/** Cmd+Backspace (macOS): 현재 위치에서 줄 시작까지 삭제 */ function deleteToLineStart(this: any): void { if (this.cursor.hasSelection()) { this.deleteSelection(); return; } - const pos = this.cursor.getPosition(); this.cursor.setAnchor(); this.cursor.moveToLineStart(); if (this.cursor.hasSelection()) { @@ -961,35 +960,43 @@ function deleteToLineStart(this: any): void { } } -/** Alt/Option+Backspace: 이전 단어 경계까지 삭제 */ +/** Ctrl+Backspace (Win/Linux) / Alt+Backspace (macOS): 이전 단어 경계까지 삭제 */ function deleteWordBackward(this: any): void { if (this.cursor.hasSelection()) { this.deleteSelection(); return; } const pos = this.cursor.getPosition(); - const { sectionIndex: sec, paragraphIndex: para, charOffset } = pos; + const inCell = this.cursor.isInCell(); + const charOffset = inCell ? (pos.cellCharOffset ?? pos.charOffset) : pos.charOffset; if (charOffset === 0) { - // 문단 시작 → 일반 Backspace와 동일 (이전 문단과 병합) - this.handleBackspace(pos, this.cursor.isInCell()); + this.handleBackspace(pos, inCell); return; } - const wordStart = findWordBoundaryBackward(this.wasm, sec, para, charOffset); + const text = getTextBeforeCursor(this.wasm, pos, inCell, charOffset); + const wordStart = findWordBoundaryInText(text); if (wordStart < charOffset) { this.cursor.setAnchor(); - this.cursor.moveTo({ ...pos, charOffset: wordStart }); + const target = inCell + ? { ...pos, cellCharOffset: wordStart, charOffset: wordStart } + : { ...pos, charOffset: wordStart }; + this.cursor.moveTo(target); this.deleteSelection(); } } -/** 문단 텍스트에서 이전 단어 경계를 찾는다 */ -function findWordBoundaryBackward(wasm: WasmBridge, sec: number, para: number, offset: number): number { - if (offset <= 0) return 0; - const text = wasm.getTextRange(sec, para, 0, offset); +function getTextBeforeCursor(wasm: WasmBridge, pos: any, inCell: boolean, offset: number): string { + if (inCell && pos.parentParaIndex != null && pos.controlIndex != null && pos.cellIndex != null && pos.cellParaIndex != null) { + try { + return wasm.getTextInCell(pos.sectionIndex, pos.parentParaIndex, pos.controlIndex, pos.cellIndex, pos.cellParaIndex, 0, offset); + } catch { /* fallback */ } + } + return wasm.getTextRange(pos.sectionIndex, pos.paragraphIndex, 0, offset); +} + +function findWordBoundaryInText(text: string): number { let i = text.length - 1; - // 1) 커서 직전의 공백/구두점 건너뛰기 while (i >= 0 && isWordSeparator(text[i])) i--; - // 2) 단어 문자 건너뛰기 while (i >= 0 && !isWordSeparator(text[i])) i--; return i + 1; }