From 3a4dc18cf10748773ee52d9dd7250137ca250617 Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 24 Aug 2026 15:28:59 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EB=9D=BC=EC=9D=B8=EB=84=98?= =?UTF-8?q?=EB=B2=84=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=9D=84=20=EB=81=84=EB=8A=94=20=EC=97=94=EC=A7=84=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enableLineSelectionDrag(기본 true)를 추가한다. false면 라인넘버 pointerdown에서 즉시 선택하는 대신 pendingLineSelect 세션으로 미루고, pointermove가 다른 행으로 건너가는 순간 세션을 취소한다 — 그 제스처는 드래그고 드래그는 아무것도 선택하지 않는다. 같은 행 안 지터는 무시하고, 그대로 놓으면 클릭으로 확정한다(한 줄 선택·shift클릭 확장·재클릭 해제, notifySelectionStart/End/Committed 시퀀스까지 기존 pointerdown 경로와 동일 — 분기 본문은 extendSelectionFromShiftClick/ selectSingleLineFromPoint로 공유한다). "+" 버튼 드래그(gutterSelecting)는 그대로다. 기본값이 true라 라이브러리 소비자의 동작은 바뀌지 않는다. --- packages/diffs/src/components/CodeView.ts | 2 + .../diffs/src/managers/InteractionManager.ts | 190 +++++++++++++++--- 2 files changed, 165 insertions(+), 27 deletions(-) diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index 472cd00..6646184 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -254,6 +254,7 @@ const CODE_VIEW_DIFF_OPTION_KEYS = [ 'enableGutterUtility', '__debugPointerEvents', 'enableLineSelection', + 'enableLineSelectionDrag', 'controlledSelection', 'disableErrorHandling', ] as const; @@ -278,6 +279,7 @@ const CODE_VIEW_FILE_OPTION_KEYS = [ 'enableGutterUtility', '__debugPointerEvents', 'enableLineSelection', + 'enableLineSelectionDrag', 'controlledSelection', 'disableErrorHandling', ] as const; diff --git a/packages/diffs/src/managers/InteractionManager.ts b/packages/diffs/src/managers/InteractionManager.ts index 7a6b169..455fe80 100644 --- a/packages/diffs/src/managers/InteractionManager.ts +++ b/packages/diffs/src/managers/InteractionManager.ts @@ -163,6 +163,16 @@ interface SessionSelecting { pointerId: number; } +// Drag-less line selection (enableLineSelectionDrag: false): the press +// selects nothing until release. A move into another row cancels the +// session — that gesture is a drag, and drags must not select. +interface SessionPendingLineSelect { + mode: 'pendingLineSelect'; + pointerId: number; + info: SelectionInfo; + shiftKey: boolean; +} + interface SessionPendingSingleLineUnselect { mode: 'pendingSingleLineUnselect'; pointerId: number; @@ -180,6 +190,7 @@ interface SessionGutterSelecting { type PointerSession = | SessionIdle | SessionSelecting + | SessionPendingLineSelect | SessionPendingSingleLineUnselect | SessionGutterSelecting; @@ -199,6 +210,13 @@ export interface InteractionManagerBaseOptions< onTokenLeave?(props: OnTokenEventProps, event: PointerEvent): unknown; __debugPointerEvents?: LogTypes; enableLineSelection?: boolean; + // When false, a press on a line number no longer selects on pointerdown + // and dragging never extends the selection: the session stays pending and + // commits only when the pointer is released on the same row (plain click, + // shift-click extension, re-click-to-unselect all keep working). The + // gutter utility drag (pressing "+") is unaffected. Defaults to true + // (GitHub-style drag selection). + enableLineSelectionDrag?: boolean; controlledSelection?: boolean; onLineSelected?: (range: SelectedLineRange | null) => void; onLineSelectionStart?: (range: SelectedLineRange | null) => void; @@ -667,7 +685,8 @@ export class InteractionManager { const hasActiveLineSelectionSession = this.pointerSession.mode === 'selecting' || - this.pointerSession.mode === 'pendingSingleLineUnselect'; + this.pointerSession.mode === 'pendingSingleLineUnselect' || + this.pointerSession.mode === 'pendingLineSelect'; const hasActiveGutterSelectionSession = this.pointerSession.mode === 'gutterSelecting'; if ( @@ -740,7 +759,10 @@ export class InteractionManager { }; private startLineSelectionFromPointerDown(event: PointerEvent): void { - const { enableLineSelection = false } = this.options; + const { + enableLineSelection = false, + enableLineSelectionDrag = true, + } = this.options; if (!enableLineSelection) { return; } @@ -763,32 +785,29 @@ export class InteractionManager { // text selection. Calling preventDefault would also cancel the browser's // native focus change, which stops a focusable host wrapper from receiving // focus (and the keyboard events it needs) when a line number is clicked. - const { lineNumber, eventSide, lineIndex } = pointerInfo; + const { lineNumber, eventSide } = pointerInfo; + + // Drag-less mode parks the whole decision on release: selecting here + // would make any drag off the row select its first row. Jitter inside + // the anchor row is tolerated by the move handler, and release on the + // same row commits through commitPendingLineSelect with the same + // click / shift-click / unselect branches as below. + if (!enableLineSelectionDrag) { + this.pointerSession = { + mode: 'pendingLineSelect', + pointerId: event.pointerId, + info: pointerInfo, + shiftKey: event.shiftKey, + }; + this.attachDocumentPointerListeners(); + return; + } if (event.shiftKey && this.selectedRange != null) { - const rowRange = this.getIndexesFromSelection( - this.selectedRange, - pre.getAttribute('data-diff-type') === 'split' - ); - if (rowRange == null) { - return; + if (this.extendSelectionFromShiftClick(pointerInfo)) { + this.pointerSession = { mode: 'selecting', pointerId: event.pointerId }; + this.attachDocumentPointerListeners(); } - const useStart = - rowRange.start <= rowRange.end - ? lineIndex >= rowRange.start - : lineIndex <= rowRange.end; - this.selectionAnchor = { - lineNumber: useStart - ? this.selectedRange.start - : this.selectedRange.end, - side: useStart - ? this.selectedRange.side - : (this.selectedRange.endSide ?? this.selectedRange.side), - }; - this.updateSelection(lineNumber, eventSide, false); - this.notifySelectionStart(this.getCurrentSelectionRange()); - this.pointerSession = { mode: 'selecting', pointerId: event.pointerId }; - this.attachDocumentPointerListeners(); return; } @@ -808,6 +827,53 @@ export class InteractionManager { return; } + this.selectSingleLineFromPoint(pointerInfo); + this.pointerSession = { mode: 'selecting', pointerId: event.pointerId }; + this.attachDocumentPointerListeners(); + } + + // Shift+click extension of an existing selection. Shared by the + // drag-enabled pointerdown path and the drag-less pendingLineSelect + // commit so both produce the same selection for the same press. Returns + // false when the existing selection's rows cannot be resolved, in which + // case selection state is left untouched. + private extendSelectionFromShiftClick( + pointerInfo: SelectionInfo + ): boolean { + const { pre } = this; + if (pre == null || this.selectedRange == null) { + return false; + } + const { lineNumber, eventSide, lineIndex } = pointerInfo; + const rowRange = this.getIndexesFromSelection( + this.selectedRange, + pre.getAttribute('data-diff-type') === 'split' + ); + if (rowRange == null) { + return false; + } + const useStart = + rowRange.start <= rowRange.end + ? lineIndex >= rowRange.start + : lineIndex <= rowRange.end; + this.selectionAnchor = { + lineNumber: useStart + ? this.selectedRange.start + : this.selectedRange.end, + side: useStart + ? this.selectedRange.side + : (this.selectedRange.endSide ?? this.selectedRange.side), + }; + this.updateSelection(lineNumber, eventSide, false); + this.notifySelectionStart(this.getCurrentSelectionRange()); + return true; + } + + // Plain click-select of one row. Shared by the drag-enabled pointerdown + // path and the drag-less pendingLineSelect commit (same reasoning as + // extendSelectionFromShiftClick). + private selectSingleLineFromPoint(pointerInfo: SelectionInfo): void { + const { lineNumber, eventSide } = pointerInfo; if (this.options.controlledSelection === true) { this.proposedSelectedRange = null; } else { @@ -817,8 +883,36 @@ export class InteractionManager { this.selectionAnchor = { lineNumber, side: eventSide }; this.updateSelection(lineNumber, eventSide, false); this.notifySelectionStart(this.getCurrentSelectionRange()); - this.pointerSession = { mode: 'selecting', pointerId: event.pointerId }; - this.attachDocumentPointerListeners(); + } + + // Drag-less line selection commits on release. Mirrors the pointerdown + // branches of the drag-enabled mode — plain click, shift-click extension + // and re-click-to-unselect — then runs the same commit sequence as the + // 'selecting' pointerup. + private commitPendingLineSelect(): void { + const session = this.pointerSession; + if (session.mode !== 'pendingLineSelect') { + return; + } + const { info, shiftKey } = session; + if (shiftKey && this.selectedRange != null) { + this.extendSelectionFromShiftClick(info); + } else if ( + this.selectedRange?.start === info.lineNumber && + this.selectedRange?.end === info.lineNumber + ) { + // Re-clicking the sole selected row clears it — the drag-enabled mode + // reaches the same outcome via pendingSingleLineUnselect. + this.updateSelection(null, undefined, false); + } else { + this.selectSingleLineFromPoint(info); + } + this.selectionAnchor = undefined; + this.detachDocumentPointerListeners(); + this.clearPointerSession(); + this.notifySelectionEnd(this.getCurrentSelectionRange()); + this.notifySelectionCommitted(); + this.clearProposedSelection(); } private startGutterSelectionFromPointerDown(event: PointerEvent): void { @@ -881,6 +975,37 @@ export class InteractionManager { } return; } + case 'pendingLineSelect': { + if (event.pointerId !== this.pointerSession.pointerId) { + return; + } + const pointerInfo = this.resolveSelectionInfo(event, { + source: 'coordinates-first', + requireNumberColumn: false, + }); + // An unresolvable pointer (off the diff, unrendered row) keeps the + // session pending — releasing back on the anchor row still commits. + if (pointerInfo == null) { + return; + } + if ( + areSelectionPointsEqual( + { lineNumber: pointerInfo.lineNumber, side: pointerInfo.eventSide }, + { + lineNumber: this.pointerSession.info.lineNumber, + side: this.pointerSession.info.eventSide, + }, + ) + ) { + return; + } + // Crossing into another row makes the gesture a drag — and a drag + // selects nothing in this mode. No preventDefault: the interaction + // is opted out entirely, so native behavior may proceed. + this.clearPointerSession(); + this.detachDocumentPointerListeners(); + return; + } case 'selecting': { if (event.pointerId !== this.pointerSession.pointerId) { return; @@ -966,6 +1091,14 @@ export class InteractionManager { this.detachDocumentPointerListeners(); return; } + case 'pendingLineSelect': { + if (event.pointerId !== this.pointerSession.pointerId) { + return; + } + event.preventDefault(); + this.commitPendingLineSelect(); + return; + } case 'pendingSingleLineUnselect': { if (event.pointerId !== this.pointerSession.pointerId) { return; @@ -1001,6 +1134,7 @@ export class InteractionManager { return; case 'gutterSelecting': case 'selecting': + case 'pendingLineSelect': case 'pendingSingleLineUnselect': { if ('pointerId' in this.pointerSession) { if (event.pointerId !== this.pointerSession.pointerId) { @@ -1974,6 +2108,7 @@ export function pluckInteractionOptions( renderGutterUtility, __debugPointerEvents, enableLineSelection, + enableLineSelectionDrag, controlledSelection, onLineSelected, onLineSelectionStart, @@ -2011,6 +2146,7 @@ export function pluckInteractionOptions( __debugPointerEvents, enableLineSelection, + enableLineSelectionDrag, controlledSelection, onLineSelected, onLineSelectionStart, From 01144312078a74f3e6b9194afbf50f6f055392c2 Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 24 Aug 2026 15:29:13 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EA=B7=B8=EB=9E=A9=20=ED=8C=9D?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EC=97=90=20=E2=8C=A5=E2=8F=8E=20=EB=8B=A8?= =?UTF-8?q?=EC=88=9C=20=EB=B3=B5=EC=82=AC=C2=B7=EB=8B=A8=EC=B6=95=ED=82=A4?= =?UTF-8?q?=20=EA=B0=81=EC=A3=BC=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=8B=AB=ED=9E=98=EC=9D=84=20=EB=B9=A0=EB=A5=B4?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 뷰어에 enableLineSelectionDrag: false를 적용 — 라인넘버 드래그는 더 이상 줄 선택을 만들지 않는다. 드래그는 코드 텍스트 선택(그랩)의 제스처다. - ⌥⏎(Option/Alt+Enter)는 프롬프트·펜스·File:/Lines: 머리말 없이 잡은 코드 텍스트만 클립보드로 복사한다(plainSnippet). 성공/실패·자동 닫힘은 제출과 같은 경로를 공유하고 IME 가드도 앞선다. Shift+Enter 개행은 그대로. - 복사 성공 후 자동 닫힘을 1200ms → 400ms로. 확인("Copied"+초록 체크)으로서 최소 체류만 남긴다(기존 1200은 copyButton RESET_MS와 맞춘 값이었다). - 팝오버 하단에 ⌥⏎ Copy code only 각주를 상시 고지한다 — .grab-label과 같은 어휘(11px·정수 행고·--vd-fg-muted)에 키 글리프만 #c2c2ca로 밝힌다. 2026-07-30 스펙이 힌트 줄을 지운 건 Enter/Esc 이중 고지 때문이었고, 이번 각주는 새 액션의 유일한 고지 채널이라 겹침이 없다. 상시 줄이 생겼으므로 POPOVER_SIZE.height 190 → 211. - 유닛 8종·e2e 2종(⑯ 드래그 무선택/클릭 유지, ⑰ ⌥⏎ 순수 복사) 추가, 스펙 문서 작성. --- apps/viewer/__tests__/grab-encode.test.ts | 27 +++++++ apps/viewer/__tests__/grab-popover.test.ts | 73 +++++++++++++++-- apps/viewer/browser/grab/encode.ts | 8 ++ apps/viewer/browser/grab/popover.ts | 54 ++++++++++--- apps/viewer/browser/main.ts | 14 +++- apps/viewer/e2e/grab.e2e.ts | 94 +++++++++++++++++++++- apps/viewer/index.html | 14 ++++ 7 files changed, 262 insertions(+), 22 deletions(-) diff --git a/apps/viewer/__tests__/grab-encode.test.ts b/apps/viewer/__tests__/grab-encode.test.ts index e971f97..861e567 100644 --- a/apps/viewer/__tests__/grab-encode.test.ts +++ b/apps/viewer/__tests__/grab-encode.test.ts @@ -3,6 +3,7 @@ import { encodeGrab, grabLabel, grabLabelParts, + plainSnippet, } from "../browser/grab/encode.ts"; import type { Snippet } from "../browser/grab/snippet.ts"; @@ -112,6 +113,32 @@ describe("encodeGrab", () => { }); }); +describe("plainSnippet", () => { + // ⌥⏎ 단순 복사 — 편집기에 바로 붙여넣을 수 있어야 하므로 펜스·헤더가 + // 없고, 문자 슬라이스가 이미 적용된 lines가 그대로 나간다. + test("side: 코드 줄만, 펜스·헤더 없음", () => { + expect(plainSnippet(sideSnip)).toBe("if (a) return;\nconst b = 1;"); + }); + + // mixed의 +/- 마커도 제외 — 맥락(헤더)이 빠진 텍스트에 마커는 노이즈다. + test("mixed: 마커 없이 텍스트만", () => { + expect( + plainSnippet({ + kind: "mixed", + oldStart: 2, + oldEnd: 2, + newStart: 2, + newEnd: 3, + rows: [ + { marker: "-", text: "l2-old", oldNo: 2, newNo: null }, + { marker: "+", text: "l2-new", oldNo: null, newNo: 2 }, + { marker: " ", text: "l3", oldNo: 3, newNo: 3 }, + ], + }), + ).toBe("l2-old\nl2-new\nl3"); + }); +}); + describe("grabLabelParts", () => { // 조각의 kind가 곧 색이다 — 여기가 틀리면 팝오버가 엉뚱한 색을 칠한다. test("side: 파일 / 범위 / 구분자 / side 네 조각", () => { diff --git a/apps/viewer/__tests__/grab-popover.test.ts b/apps/viewer/__tests__/grab-popover.test.ts index 258e941..4a39dcc 100644 --- a/apps/viewer/__tests__/grab-popover.test.ts +++ b/apps/viewer/__tests__/grab-popover.test.ts @@ -29,6 +29,7 @@ const openDefault = () => label: labelParts("main.ts", ":84-98"), labelTitle: "apps/viewer/browser/main.ts", buildOutput: (prompt) => `OUT[${prompt}]`, + buildPlainOutput: () => "PLAIN", placement: { left: 10, top: 20 }, }); const input = () => @@ -157,10 +158,40 @@ describe("createGrabPopover", () => { expect(writes).toEqual(["OUT[정리해줘]"]); expect(copied).toBe(1); expect(popover.element.textContent).toContain("Copied"); - jest.advanceTimersByTime(1200); + jest.advanceTimersByTime(400); expect(popover.isOpen()).toBe(false); jest.useRealTimers(); }); + // ⌥⏎ 단순 복사 — 프롬프트가 차 있어도 무시하고 잡은 코드 텍스트만 나간다. + // Enter 계열은 개행 기본 동작이 있으므로 취소도 확인한다(Shift+Enter만 예외). + test("Alt+Enter는 buildPlainOutput을 복사하고 기본 개행을 막는다", async () => { + openDefault(); + input().value = "무시될 프롬프트"; + const notCancelled = pressEnter({ altKey: true }); + await flush(); + expect(notCancelled).toBe(false); + expect(writes).toEqual(["PLAIN"]); + expect(copied).toBe(1); + }); + + test("Alt+Enter도 Copied 상태 후 400ms 자동 닫힘", async () => { + jest.useFakeTimers(); + openDefault(); + pressEnter({ altKey: true }); + await flush(); + expect(popover.element.textContent).toContain("Copied"); + jest.advanceTimersByTime(400); + expect(popover.isOpen()).toBe(false); + jest.useRealTimers(); + }); + + test("IME 조합 중 Alt+Enter는 무시", () => { + openDefault(); + pressEnter({ altKey: true, isComposing: true }); + expect(writes).toEqual([]); + expect(popover.isOpen()).toBe(true); + }); + test("IME 조합 중 Enter(isComposing)는 무시", () => { openDefault(); input().value = "한글"; @@ -254,7 +285,7 @@ describe("createGrabPopover", () => { // 복사 성공 직후엔 아직 열려 있다 — onCopied만 발화하고 onClosed는 아직. expect(copied).toBe(1); expect(closed).toBe(0); - jest.advanceTimersByTime(1200); + jest.advanceTimersByTime(400); expect(closed).toBe(1); jest.useRealTimers(); }); @@ -273,6 +304,7 @@ describe("createGrabPopover", () => { label: labelParts("x", ""), labelTitle: "src/x.ts", buildOutput: () => "y", + buildPlainOutput: () => "plain", placement: { left: 0, top: 0 }, }); expect(() => p.close()).not.toThrow(); @@ -289,6 +321,7 @@ describe("상태 전용 슬롯 + 접근성", () => { label: labelParts(), labelTitle: "src/a.ts", buildOutput: () => "out", + buildPlainOutput: () => "plain", placement: { left: 0, top: 0 }, }); const hint = pop.element.querySelector(".grab-hint") as HTMLElement; @@ -297,21 +330,39 @@ describe("상태 전용 슬롯 + 접근성", () => { expect(send.dataset.state).toBe("idle"); }); - // 단축키 고지는 **화면을 차지하지 않는** 두 채널이 맡는다: 버튼 hover(title)와 - // aria-keyshortcuts. placeholder는 한 마디만 해야 한다 — 여기에 안내를 실으면 - // 입력창을 꽉 채워 창에서 가장 눈에 띄는 요소가 되어버린다. - test("placeholder는 한 마디만 하고, 단축키는 title·aria가 고지한다", () => { + // 단축키 고지는 세 채널이 맡는다: 하단 .grab-keys 각주(⌥⏎만), 버튼 + // hover(title), aria-keyshortcuts. placeholder는 한 마디만 해야 한다 — + // 여기에 안내를 실으면 입력창을 꽉 채워 창에서 가장 눈에 띄는 요소가 된다. + test("placeholder는 한 마디만 하고, 단축키는 각주·title·aria가 고지한다", () => { const { popover: pop } = makePopover(); const box = pop.element.querySelector("textarea") as HTMLTextAreaElement; expect(box.placeholder).toBe("Prompt…"); expect(box.getAttribute("aria-keyshortcuts")).toBe( - "Enter Shift+Enter Escape", + "Enter Shift+Enter Alt+Enter Escape", ); - // 표기(글자/글리프)가 아니라 **두 동작이 다 고지되는가**를 본다 — + // 표기(글자/글리프)가 아니라 **동작이 다 고지되는가**를 본다 — // 문구를 다듬어도 안내가 통째로 빠지는 회귀만 잡히면 된다. const send = pop.element.querySelector(".grab-send") as HTMLElement; expect(send.title).toContain("⏎"); expect(send.title).toContain("new line"); + expect(send.title).toContain("plain code"); + }); + + // 하단 각주는 ⌥⏎의 발견 가능성 채널 — 상태 라이브 리전과 달리 항상 보인다. + test("하단에 ⌥⏎ 단축키 각주가 렌더된다", () => { + const { popover: pop } = makePopover(); + pop.open({ + label: labelParts(), + labelTitle: "src/a.ts", + buildOutput: () => "out", + buildPlainOutput: () => "plain", + placement: { left: 0, top: 0 }, + }); + const keys = pop.element.querySelector(".grab-keys") as HTMLElement; + expect(keys.textContent).toBe("⌥⏎ Copy code only"); + // 키 글리프와 설명이 갈라져 있다 — 키 쪽만 밝은 톤으로 칠한다. + const key = keys.querySelector(".grab-keys-k") as HTMLElement; + expect(key.textContent).toBe("⌥⏎"); }); test("복사 실패는 힌트에 표시되고 팝오버는 열린 채 남는다", async () => { @@ -320,6 +371,7 @@ describe("상태 전용 슬롯 + 접근성", () => { label: labelParts(), labelTitle: "src/a.ts", buildOutput: () => "out", + buildPlainOutput: () => "plain", placement: { left: 0, top: 0 }, }); const box = pop.element.querySelector("textarea") as HTMLTextAreaElement; @@ -339,6 +391,7 @@ describe("상태 전용 슬롯 + 접근성", () => { label: labelParts(), labelTitle: "src/a.ts", buildOutput: () => "out", + buildPlainOutput: () => "plain", placement: { left: 0, top: 0 }, }; pop.open(opts); @@ -369,6 +422,7 @@ describe("상태 전용 슬롯 + 접근성", () => { ], labelTitle: "src/a.ts", buildOutput: () => "out", + buildPlainOutput: () => "plain", placement: { left: 0, top: 0 }, }); const label = pop.element.querySelector(".grab-label") as HTMLElement; @@ -389,6 +443,7 @@ describe("상태 전용 슬롯 + 접근성", () => { const base = { labelTitle: "src/a.ts", buildOutput: () => "out", + buildPlainOutput: () => "plain", placement: { left: 0, top: 0 }, }; pop.open({ ...base, label: labelParts("a.ts", ":1-2") }); @@ -404,6 +459,7 @@ describe("상태 전용 슬롯 + 접근성", () => { label: labelParts(), labelTitle: "src/deep/a.ts", buildOutput: () => "out", + buildPlainOutput: () => "plain", placement: { left: 0, top: 0 }, }); const label = pop.element.querySelector(".grab-label"); @@ -434,6 +490,7 @@ describe("보내기 버튼 — 입력 영역 안, 배경 없음", () => { label: labelParts(), labelTitle: "src/a.ts", buildOutput: (p: string) => `out:${p}`, + buildPlainOutput: () => "plain", placement: { left: 0, top: 0 }, }; diff --git a/apps/viewer/browser/grab/encode.ts b/apps/viewer/browser/grab/encode.ts index 2db080f..1bf8923 100644 --- a/apps/viewer/browser/grab/encode.ts +++ b/apps/viewer/browser/grab/encode.ts @@ -80,6 +80,14 @@ export const grabLabel = (path: string, snippet: Snippet): string => .map((part) => part.text) .join(""); +// ⌥⏎ 단순 복사용 — 잡은 줄의 코드 텍스트만 나간다. 펜스·File:/Lines: 머리말· +// 프롬프트 전부 없이 편집기에 바로 붙여넣을 수 있는 형태. mixed의 +/- 마커도 +// 싣지 않는다 — 맥락(헤더)이 빠진 텍스트에 마커만 남으면 노이즈다. +export const plainSnippet = (snippet: Snippet): string => + snippet.kind === "side" + ? snippet.lines.join("\n") + : snippet.rows.map((r) => r.text).join("\n"); + export const encodeGrab = (input: EncodeInput): string => { const { snippet } = input; const fileLine = input.prevPath diff --git a/apps/viewer/browser/grab/popover.ts b/apps/viewer/browser/grab/popover.ts index 058f85a..f08c86c 100644 --- a/apps/viewer/browser/grab/popover.ts +++ b/apps/viewer/browser/grab/popover.ts @@ -3,13 +3,15 @@ // findBar.ts와 동일하게 리스너는 생성 시 등록, destroy()에서 해제(happy-dom // 전역 window 누적 방지). // -// 구조는 라벨 한 줄 + 입력 영역 둘뿐이고, 보내기 버튼은 입력 영역 **안**에 산다: +// 구조는 라벨 한 줄 + 입력 영역 + 단축키 각주 한 줄 뿐이고, 보내기 버튼은 +// 입력 영역 **안**에 산다: // // ┌ #grab-popover ────────────────┐ 8px ← 떠 있는 패널(앱에서 유일) // │ popover.ts:53 · new side │ // │ ┌ .grab-field ──────────────┐ │ 6px ← --vd-radius, 표준 컨트롤 // │ │ textarea [.grab-send]│ │ 4px ← 컨테이너 안쪽 버튼 // │ └───────────────────────────┘ │ +// │ ⌥⏎ Copy code only │ 6px ← .grab-keys 각주(--vd-fg-muted) // └───────────────────────────────┘ // // radius 8/6/4는 임의값이 아니라 앱에 이미 있는 세 값이다(툴바 세그먼트 @@ -33,6 +35,8 @@ export interface GrabOpenOptions { label: GrabLabelPart[]; labelTitle: string; // 전체 경로 — 라벨은 basename만 보여주고 ellipsis로 잘린다 buildOutput(prompt: string): string; + // ⌥⏎가 복사할 "단순 코드" 출력 — 프롬프트·머리말 없이 잡은 코드만. + buildPlainOutput(): string; placement: Placement; } @@ -47,8 +51,13 @@ export interface GrabPopover { const HINT_COPIED = "Copied"; const HINT_FAILED = "Copy failed"; const SEND_LABEL = "Copy to clipboard"; -const SEND_TITLE = "Copy (⏎) · Shift+⏎ for new line"; -const AUTO_CLOSE_MS = 1200; +const SEND_TITLE = "Copy (⏎) · Shift+⏎ for new line · ⌥⏎ for plain code"; +const KEYS_KEY = "⌥⏎"; +const KEYS_TEXT = "Copy code only"; +// 복사 성공 확인("Copied" + 초록 체크)의 체류 시간. 예전엔 copyButton의 +// RESET_MS(1200)와 맞췄지만, 제출 후 팝오버가 빨리 사라지는 게 요청돼서 +// 확인으로서의 최소한만 남긴다. +const AUTO_CLOSE_MS = 400; // 버튼 아이콘 셋. 세 개를 전부 DOM에 두고 data-state로 CSS가 하나만 보여준다 — // 상태마다 innerHTML을 갈아끼우면 매번 파서를 태우고 재측정을 유발한다. @@ -90,11 +99,11 @@ export const createGrabPopover = (deps: GrabPopoverDeps): GrabPopover => { input.className = "grab-input"; // placeholder는 한 마디만 한다. 예전엔 여기에 "(⏎ copy · shift + ⏎ new // line)"까지 실어 입력창을 꽉 채웠는데, 그게 창에서 가장 눈에 띄는 요소라 - // 다른 걸 아무리 고쳐도 "안 바뀐 것처럼" 보였다. 단축키 고지는 이제 화면을 - // 차지하지 않는 두 채널이 맡는다 — 버튼 hover(title)와 aria-keyshortcuts. + // 다른 걸 아무리 고쳐도 "안 바뀐 것처럼" 보였다. 단축키 고지는 하단 + // .grab-keys 각주(⌥⏎만)와 버튼 hover(title), aria-keyshortcuts가 맡는다. input.placeholder = "Prompt…"; input.setAttribute("aria-label", "Grab prompt"); - input.setAttribute("aria-keyshortcuts", "Enter Shift+Enter Escape"); + input.setAttribute("aria-keyshortcuts", "Enter Shift+Enter Alt+Enter Escape"); // 보내기 버튼. 배경색이 없는 게 계약이다 — 색만 바뀐다(회색 → 액센트 → // 초록/빨강). 그래서 어느 상태에서도 창 크기가 변하지 않는다. @@ -112,6 +121,15 @@ export const createGrabPopover = (deps: GrabPopoverDeps): GrabPopover => { field.append(input, send); + // 하단 단축키 각주 — ⌥⏎의 발견 가능성 채널. 항상 보이는 고정 텍스트라 + // 상태를 알리는 sr-only 라이브 리전(.grab-hint)과 역할이 다르다. + const keys = doc.createElement("div"); + keys.className = "grab-keys"; + const keysKey = doc.createElement("span"); + keysKey.className = "grab-keys-k"; + keysKey.textContent = KEYS_KEY; + keys.append(keysKey, ` ${KEYS_TEXT}`); + // 상태 전용 라이브 리전. **시각적으로는 버튼이 상태를 지므로** 이 노드는 // 스크린리더 전용(sr-only)이다. 예전처럼 눈에 보이는 줄로 두면 복사할 // 때마다 창이 한 줄 자라 커서 아래 코드가 밀렸다(실측: 66 → 84px). @@ -133,10 +151,11 @@ export const createGrabPopover = (deps: GrabPopoverDeps): GrabPopover => { hint.textContent = ""; }; - element.append(label, field, hint); + element.append(label, field, keys, hint); let opened = false; let buildOutput: ((prompt: string) => string) | null = null; + let buildPlainOutput: (() => string) | null = null; let autoCloseTimer: ReturnType | null = null; const clearAutoCloseTimer = (): void => { @@ -167,6 +186,7 @@ export const createGrabPopover = (deps: GrabPopoverDeps): GrabPopover => { // options.buildOutput을 그대로 tear-off하지 않고 래핑 호출한다 // (oxlint unbound-method — 인터페이스 메서드 시그니처의 this 바인딩 경고). buildOutput = (prompt) => options.buildOutput(prompt); + buildPlainOutput = () => options.buildPlainOutput(); element.style.left = `${options.placement.left}px`; element.style.top = `${options.placement.top}px`; input.value = ""; @@ -195,11 +215,20 @@ export const createGrabPopover = (deps: GrabPopoverDeps): GrabPopover => { console.warn(err); }; - const submit = (): void => { - const output = buildOutput?.(input.value) ?? ""; + const writeOutput = (output: string): void => { deps.writeText(output).then(onCopySuccess, onCopyFailure); }; + const submit = (): void => { + writeOutput(buildOutput?.(input.value) ?? ""); + }; + + // ⌥⏎ — 프롬프트·머리말 없이 잡은 코드 텍스트만 복사한다. 성공/실패와 + // 자동 닫힘은 제출과 같은 경로를 쓴다. + const submitPlain = (): void => { + writeOutput(buildPlainOutput?.() ?? ""); + }; + // 프롬프트가 비었는지에 따라 버튼 강조만 바뀐다. 비활성화하지는 않는다 — // 빈 프롬프트로도 참조+스니펫은 복사되므로 막으면 기능이 줄어든다. input.addEventListener("input", () => { @@ -209,6 +238,13 @@ export const createGrabPopover = (deps: GrabPopoverDeps): GrabPopover => { input.addEventListener("keydown", (event) => { if (event.key === "Enter") { if (event.isComposing || event.keyCode === 229) return; + // ⌥⏎는 단순 복사 — Enter 계열은 전부 개행 기본 동작이 있으므로 여기서 + // 막는다(Shift+Enter만 예외로 살려 둔다). + if (event.altKey) { + event.preventDefault(); + submitPlain(); + return; + } // Shift+Enter는 개행 — preventDefault를 부르지 않고 그냥 빠져나가 // textarea의 기본 동작에 맡긴다. 새 키 분기를 만들지 않는다. if (event.shiftKey) return; diff --git a/apps/viewer/browser/main.ts b/apps/viewer/browser/main.ts index 81b6461..44997b5 100644 --- a/apps/viewer/browser/main.ts +++ b/apps/viewer/browser/main.ts @@ -25,6 +25,7 @@ import { encodeGrab, type GrabFileStatus, grabLabelParts, + plainSnippet, } from "./grab/encode.ts"; import { createGrabHighlighter, @@ -391,10 +392,11 @@ const autoExpandedIds = new Set(); // 검색이 임시로 펼친 대용 // 없어 content-box라 실제 렌더 폭은 340 + 패딩 16 + 테두리 2다. 340으로 두면 // 우측 클램프(viewport.width - size.width - MARGIN)가 18px 관대해져 화면 // 오른쪽 끝에서 드래그할 때 팝오버가 그만큼 잘린다(실측 358×69). -// height는 입력창이 max-height(96px)까지 자라고 상태 줄까지 뜬 최대 상태를 -// 기준으로 잡는다 — 배치는 open() 때 한 번만 계산되므로, 자란 뒤 재배치가 -// 없어서 과소 선언하면 화면 아래쪽에서 카드가 뷰포트를 벗어난다. -const POPOVER_SIZE = { width: 358, height: 190 }; +// height는 입력창이 max-height(96px)까지 자라고 하단 단축키 각주(.grab-keys, +// 15px + gap 6px)까지 뜬 최대 상태를 기준으로 잡는다 — 배치는 open() 때 한 번만 +// 계산되므로, 자란 뒤 재배치가 없어서 과소 선언하면 화면 아래쪽에서 카드가 +// 뷰포트를 벗어난다. +const POPOVER_SIZE = { width: 358, height: 211 }; const viewport = (): { width: number; height: number } => ({ width: window.innerWidth, height: window.innerHeight, @@ -523,6 +525,7 @@ const buildGrabSnapshot = ( label: grabLabelParts(fileId, snippet), labelTitle: fileId, buildOutput: (prompt) => encodeGrab({ ...input, prompt }), + buildPlainOutput: () => plainSnippet(snippet), }; }; @@ -627,6 +630,9 @@ const codeViewOptions = (): ConstructorParameters< // diff-grab: GitHub식 거터 라인 선택 + "+" 버튼 (스펙 §경로 A). // renderGutterUtility는 금지 — onGutterUtilityClick과 병용 시 엔진 throw. enableLineSelection: true, + // 라인넘버 드래그는 끈다 — 드래그는 코드 텍스트 선택(그랩)의 제스처다. + // 클릭 한 줄 선택·shift클릭 확장·"+" 클릭은 그대로(엔진 pendingLineSelect). + enableLineSelectionDrag: false, enableGutterUtility: true, onGutterUtilityClick: (range: SelectedLineRange, context) => { const snap = buildGrabSnapshot(context.item.id, normalizeRange(range)); diff --git a/apps/viewer/e2e/grab.e2e.ts b/apps/viewer/e2e/grab.e2e.ts index 3720071..6ba4944 100644 --- a/apps/viewer/e2e/grab.e2e.ts +++ b/apps/viewer/e2e/grab.e2e.ts @@ -1,4 +1,4 @@ -// diff-grab e2e 15종: 거터/텍스트 두 경로 모두에서 실제 브라우저 제스처로 +// diff-grab e2e 17종: 거터/텍스트 두 경로 모두에서 실제 브라우저 제스처로 // 선택을 만들고(드래그·더블/트리플클릭), 팝오버·클립보드 인코딩까지 실 // Chrome으로 검증한다. // happy-dom 유닛 테스트(grab/*.test.ts)는 순수 로직만 커버하므로, @@ -696,3 +696,95 @@ test("⑮ 파일 헤더(파일명) 더블클릭은 팝오버를 열지 않는다 await page.waitForTimeout(80); await expect(page.locator("#grab-popover")).toBeHidden(); }); + +test("⑯ 라인넘버 드래그는 아무 줄도 선택하지 않는다 — 클릭 한 줄 선택은 유지", async ({ + page, + viewerUrl, +}) => { + await page.goto(viewerUrl); + await expect(page.locator("#status")).toHaveText(/\d+ file\(s\)/); + + // README.md는 순수 addition이라 unified 거터가 한 줄로 이어진다 — 세로 + // 드래그로 여러 라인넘버 셀을 가로지를 수 있다(hello.ts는 셀이 딱 2개라 + // 다른 side라 드래그 판별이 흐려진다). + const container = page + .locator("diffs-container") + .filter({ has: page.locator('[data-fold="README.md"]') }); + await expect(container).toBeVisible(); + await waitForHighlighted(container); + + const selectedCount = (): Promise => + container.evaluate( + (el) => + el.shadowRoot?.querySelectorAll("[data-selected-line]").length ?? 0, + ); + + const cells = container.locator("[data-column-number]"); + const a = await cells.first().boundingBox(); + const b = await cells.nth(2).boundingBox(); + if (!a || !b) throw new Error("gutter cells not visible"); + + // 드래그: 다른 행으로 건너가는 순간 엔진 pendingLineSelect가 취소된다 — + // 예전 GitHub식 드래그 선택(data-selected-line 스탬프)이 생기지 않는다. + await dragSelect( + page, + { x: a.x + a.width / 2, y: a.y + a.height / 2 }, + { x: b.x + b.width / 2, y: b.y + b.height / 2 }, + ); + await page.waitForTimeout(80); + expect(await selectedCount()).toBe(0); + + // 클릭(같은 셀에서 시작·끝)은 여전히 그 한 줄을 선택한다 — 드래그만 끊고 + // 클릭 선택·"+" 경로는 그대로라는 계약의 절반. rAF로 칠해지므로 poll로 기다린다. + await dragSelect( + page, + { x: a.x + a.width / 2, y: a.y + a.height / 2 }, + { x: a.x + a.width / 2, y: a.y + a.height / 2 }, + ); + await expect.poll(selectedCount).toBeGreaterThan(0); +}); + +test("⑰ ⌥+Enter는 프롬프트·머리말 없이 잡은 코드만 클립보드로", async ({ + page, + viewerUrl, + context, +}) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await page.goto(viewerUrl); + await expect(page.locator("#status")).toHaveText(/\d+ file\(s\)/); + + // ②와 같은 README.md 텍스트 드래그로 팝오버를 연다. 프롬프트를 채워 둔 + // 채 ⌥+Enter를 치면 그 프롬프트가 무시되고 잡은 코드 텍스트만 나가야 + // 한다 — 편집기에 바로 붙여넣기 위한 형식이다. + const container = page + .locator("diffs-container") + .filter({ has: page.locator('[data-fold="README.md"]') }); + await expect(container).toBeVisible(); + await waitForHighlighted(container); + + const rows = container.locator("[data-line]"); + const a = await rows.first().boundingBox(); + const b = await rows.nth(2).boundingBox(); + if (!a || !b) throw new Error("text rows not visible"); + await dragSelect( + page, + { x: a.x + 40, y: a.y + a.height / 2 }, + { x: b.x + b.width - 5, y: b.y + b.height / 2 }, + ); + const popover = page.locator("#grab-popover"); + const input = page.locator("#grab-popover textarea"); + await expect(popover).toBeVisible(); + // 하단 단축키 각주가 ⌥⏎를 고지한다 — 발견 가능성 채널. + await expect(popover.locator(".grab-keys")).toHaveText("⌥⏎ Copy code only"); + + await input.fill("이 프롬프트는 무시된다"); + await input.press("Alt+Enter"); + const out = await readClipboard(page); + // 머리말·펜스·프롬프트 전부 없이 순수 코드 텍스트만. + expect(out).not.toContain("diffdeck selection"); + expect(out).not.toContain("```"); + expect(out).not.toContain("이 프롬프트는 무시된다"); + expect(out).toContain("Base line."); + // 단순 복사도 성공 상태(Copied) 후 같은 타이머(400ms)로 자동 닫힌다. + await expect(popover).toBeHidden(); +}); diff --git a/apps/viewer/index.html b/apps/viewer/index.html index 7c52c1d..1acfb65 100644 --- a/apps/viewer/index.html +++ b/apps/viewer/index.html @@ -704,6 +704,20 @@ #grab-popover .grab-send[data-state="fail"] .i-fail { display: block; } + /* 하단 단축키 각주 — .grab-label과 같은 어휘(11px, 정수 행고, + --vd-fg-muted)로 입력창보다 한 단계 낮게 말한다. 키 글리프만 + 라벨의 파일명 톤(#c2c2ca)으로 밝혀 단축키와 설명을 가른다. */ + #grab-popover .grab-keys { + font-size: 11px; + /* 정수 행고 — 팝오버 전체 높이가 소수가 되지 않게 하는 축. + .grab-label과 같은 이유다. */ + line-height: 15px; + color: var(--vd-fg-muted); + white-space: nowrap; + } + #grab-popover .grab-keys-k { + color: #c2c2ca; + } /* 상태는 버튼이 눈으로 말하므로 이 노드는 스크린리더 전용이다. 예전처럼 보이는 줄로 두면 복사할 때마다 창이 18px 자라(실측: 66 → 84) 커서 아래 코드가 밀렸다. hidden을 토글하지 않는 이유이기도 하다 — From 6b6af4435210758142a6c7d0fe3b35e28dc3d5b7 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 28 Aug 2026 18:15:28 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test:=20=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EA=B1=B0=ED=84=B0=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=9D=98=20shift=ED=81=B4=EB=A6=AD=20=ED=99=95=EC=9E=A5=C2=B7?= =?UTF-8?q?=EC=9E=AC=ED=81=B4=EB=A6=AD=20=ED=95=B4=EC=A0=9C=20e2e=EB=A5=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⑯은 "라인넘버 드래그가 아무것도 선택하지 않는다"는 절반만 지킨다. 나머지 절반 — enableLineSelectionDrag:false에서도 클릭 계열 제스처가 드래그 켠 경로와 똑같이 동작한다 — 은 commitPendingLineSelect의 두 분기(shift클릭 확장·재클릭 해제)에 있는데 그 둘을 통째로 지워도 grab e2e 17종이 전부 통과했다(실측). ⑱을 넣은 뒤에야 빨간불이 난다. 선택 개수는 행 수가 아니라 스탬프 수다 — unified는 선택된 행마다 [data-selected-line]을 둘(번호 셀 + 내용 셀) 찍는다(실측). 그래서 한 줄은 2, 세 줄은 6이다. 픽스처의 dragSelect엔 모디파이어 인자가 없어 shift는 스펙에서 keyboard.down/up으로 감싼다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PaHtxdofJX6EvH7BWXgYKX --- apps/viewer/e2e/grab.e2e.ts | 63 ++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/viewer/e2e/grab.e2e.ts b/apps/viewer/e2e/grab.e2e.ts index 6ba4944..109839e 100644 --- a/apps/viewer/e2e/grab.e2e.ts +++ b/apps/viewer/e2e/grab.e2e.ts @@ -1,4 +1,4 @@ -// diff-grab e2e 17종: 거터/텍스트 두 경로 모두에서 실제 브라우저 제스처로 +// diff-grab e2e 18종: 거터/텍스트 두 경로 모두에서 실제 브라우저 제스처로 // 선택을 만들고(드래그·더블/트리플클릭), 팝오버·클립보드 인코딩까지 실 // Chrome으로 검증한다. // happy-dom 유닛 테스트(grab/*.test.ts)는 순수 로직만 커버하므로, @@ -788,3 +788,64 @@ test("⑰ ⌥+Enter는 프롬프트·머리말 없이 잡은 코드만 클립보 // 단순 복사도 성공 상태(Copied) 후 같은 타이머(400ms)로 자동 닫힌다. await expect(popover).toBeHidden(); }); + +test("⑱ 드래그를 끈 거터도 shift클릭 확장과 재클릭 해제는 그대로", async ({ + page, + viewerUrl, +}) => { + await page.goto(viewerUrl); + await expect(page.locator("#status")).toHaveText(/\d+ file\(s\)/); + + // ⑯은 "드래그가 아무것도 선택하지 않는다"는 절반만 지킨다. 나머지 절반 — + // enableLineSelectionDrag:false에서도 클릭 계열 제스처가 드래그 켠 경로와 + // 똑같이 동작한다 — 은 commitPendingLineSelect의 두 분기(shift클릭 확장· + // 재클릭 해제)에 있는데, 그 둘을 통째로 지워도 ⑯~⑰이 전부 통과한다(실측). + // 그래서 Foundation 예외 #5의 회귀망은 이 케이스까지 있어야 완결된다. + const container = page + .locator("diffs-container") + .filter({ has: page.locator('[data-fold="README.md"]') }); + await expect(container).toBeVisible(); + await waitForHighlighted(container); + + const selectedCount = (): Promise => + container.evaluate( + (el) => + el.shadowRoot?.querySelectorAll("[data-selected-line]").length ?? 0, + ); + + const cells = container.locator("[data-column-number]"); + const a = await cells.first().boundingBox(); + const c = await cells.nth(2).boundingBox(); + if (!a || !c) throw new Error("gutter cells not visible"); + const center = (b: { + x: number; + y: number; + width: number; + height: number; + }) => ({ + x: b.x + b.width / 2, + y: b.y + b.height / 2, + }); + + // 개수는 "행 수"가 아니라 스탬프 수다 — unified는 선택된 행마다 + // [data-selected-line]을 둘(번호 셀 + 내용 셀) 찍는다(실측). 그래서 + // 한 줄 = 2, 세 줄 = 6이다. + // 클릭 한 줄 선택(pendingLineSelect가 pointerup에서 확정). + await dragSelect(page, center(a), center(a)); + await expect.poll(selectedCount).toBe(2); + + // shift클릭으로 세 번째 행까지 확장 — 드래그를 껐어도 이 관용구는 살아 있다. + await page.keyboard.down("Shift"); + await dragSelect(page, center(c), center(c)); + await page.keyboard.up("Shift"); + await expect.poll(selectedCount).toBe(6); + + // 확장된 선택 안의 첫 행을 클릭하면 그 한 줄만 남고(해제 분기가 아니다), + await dragSelect(page, center(a), center(a)); + await expect.poll(selectedCount).toBe(2); + + // 그 한 줄을 다시 클릭하면 해제된다 — 드래그 켠 경로의 + // pendingSingleLineUnselect와 같은 결과. + await dragSelect(page, center(a), center(a)); + await expect.poll(selectedCount).toBe(0); +}); From d254b74af9cb7eab052a9be6584647cebdc3ba78 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 28 Aug 2026 18:15:29 +0900 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20=EB=9D=BC=EC=9D=B8=EB=84=98?= =?UTF-8?q?=EB=B2=84=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=EC=9D=84=20Foundation=20=EC=98=88=EC=99=B8=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0=ED=95=98=EA=B3=A0=20=EA=B7=B8=EB=9E=A9=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EB=A5=BC=20=EA=B0=B1=EC=8B=A0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포크 패키지 수정은 [diffdeck] 주석으로 upstream 이탈을 코드에 표기하고 CLAUDE.md 예외 목록에 올려야 하는데, enableLineSelectionDrag는 둘 다 빠져 있었다. InteractionManager에 7곳·CodeView에 2곳 마커를 넣고 예외를 4건 → 5건으로 등재한다(회귀망이 ⑯만으로는 미달인 근거도 함께 적는다). 같은 커밋에서 이번 브랜치가 낡게 만든 CLAUDE.md 서술 5곳을 고친다: - 진입 경로 ① — 거터는 이제 드래그 확장만 꺼진 상태다 - 자동 닫힘 1.2초 → 0.4초 - 키 분기에 ⌥⏎ 단순 복사와 "IME → altKey → shiftKey → 제출" 순서 계약 - 고지 채널 둘 → 셋. 새 .grab-keys 각주가 "상시 힌트 줄 제거"와 모순이 아닌 이유와 POPOVER_SIZE 190 → 211 - grab.e2e.ts 15종 → 18종 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PaHtxdofJX6EvH7BWXgYKX --- CLAUDE.md | 5 +++-- packages/diffs/src/components/CodeView.ts | 2 ++ packages/diffs/src/managers/InteractionManager.ts | 14 +++++++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fd5de1b..83bf916 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ cc-statusline에 포함됐던 로컬 diff 뷰어를 독립 제품으로 분리 + **함정: Bun `$` 템플릿의 비ASCII 리터럴은 뭉개진다.** `` $`git … refs/remotes/origin/기능 …` `` 처럼 **리터럴**로 적으면 `uAE30uB2A5`라는 ASCII 텍스트가 된다(실측 코드포인트 `75 41 45 33 30 …`). `${보간}`으로 넘기면 온전하다(`ae30 b2a5`). 한글 브랜치명 테스트를 리터럴로 쓰면 한글이 아닌 브랜치를 만들어 놓고 **조용히 통과**한다 — 실제로 한 번 속았다. -"Grab" 기능은 diff의 특정 구간을 프롬프트와 함께 클립보드로 복사해 AI 에이전트에게 바로 붙여넣을 수 있게 한다. 진입 경로는 둘: ① 엔진 거터의 GitHub식 라인 선택 + 상주 "+" 버튼(CodeView의 `enableLineSelection`/`enableGutterUtility` 옵션으로 활성화), ② 코드 텍스트를 선택하면(드래그 릴리스 또는 더블/트리플클릭) 즉시 열리는 프롬프트 팝오버. 둘 다 `#grab-popover` 프롬프트 입력창을 열고, Enter로 "참조(파일 경로·상태·base)+스니펫+프롬프트"를 한 텍스트로 인코딩해 클립보드에 복사한다. `enableLineSelection`/`enableGutterUtility`는 전역 활성화라 모든 라인 호버에 "+"가 뜨고 find 매치 라인에도 상주하는데, 이는 의도된 UX 변화다. 텍스트 경로는 **제스처를 두 갈래로 인지한다**. ① 드래그: `pointerup`에서 pointerDown→pointerup 이동 거리가 `DRAG_THRESHOLD`(6px)를 넘었는지 본다(헤더 폴드 토글과 동일한 `movedBeyondThreshold` 관례). ② 멀티클릭: 더블/트리플클릭의 네이티브 단어·문단 선택은 **마우스가 전혀 움직이지 않아** ①을 원리적으로 통과하지 못하므로 `click` 이벤트에서 `event.detail >= 2`로 따로 받는다. 이 갈래가 `click`에 붙는 이유는 **Chrome의 `pointerdown`/`pointerup`은 `detail`이 언제나 0이라 클릭 횟수를 실어 나르지 않기 때문**이다(실측). 이 사실은 **포인터 이벤트에 한정된다** — `mousedown`/`mouseup`은 클릭 횟수를 그대로 싣는다(실측: 1, 2). 그래도 `click`이 맞는 지점인 건 down/up이 같은 타깃일 때만 발화하고 그 시점엔 네이티브 선택이 확정돼 있어서다. `dblclick` 이벤트로 짜면 안 된다: 트리플클릭엔 전용 이벤트가 없어(dblclick은 두 번째 클릭에서 끝난다) 세 번째 클릭의 문단 선택을 놓치고 단어 스니펫에 머문다. 트리플클릭에서는 두 번째 클릭이 팝오버를 한 번 열고, 세 번째 클릭의 pointerdown이 팝오버 바깥 dismiss에 걸려 그걸 닫은 뒤 다시 열린다 — 최종 상태는 옳지만 **한 번 깜박인다.** 바깥 dismiss에 디바운스나 "N ms 안엔 재오픈 금지" 가드를 넣으면 트리플클릭이 조용히 깨지니 주의. 문턱값 `>= 2`를 **자동으로 지켜주는 테스트는 없다**: `>= 1`로 밀려도 평범한 클릭은 선택 자체를 안 만들어(collapsed range) 팝오버가 안 열리므로 ⑧이 그대로 통과한다(실측). 문턱값을 실제로 가르는 유일한 제스처는 **shift+클릭 선택 확장**(detail 1인데 살아있는 선택을 만든다)인데, 이건 지금 팝오버를 열지 않는 **알려진 제외**다 — 드래그와 같은 상태를 만드는 표준 선택 관용구인데도 빠져 있으니, 나중에 지원하기로 하면 그때 ⑧의 짝으로 문턱값 가드를 함께 세울 것. 어느 갈래든 통과하면 스냅샷 의미론: 제스처 시점(한 틱 뒤)에 선택·파일·스니펫을 전부 고정해 이후 워커 하이라이트 DOM 교체나 recycle이 선택을 죽여도 안전하고, 팝오버는 스크롤로는 닫히지 않으며 Esc·팝오버 바깥 pointerdown·`renderPatch`(파일 목록 갱신) 발생 시, 그리고 복사 성공 후 1.2초 뒤 자동으로 닫힌다. 선택 소유권: 팝오버가 엔진 라인 선택(`codeView.selectedLines` — `data-selected-line`을 낳는 슬롯)을 "소유"한 경우에만 — 즉 거터 "+" 경로(`onGutterUtilityClick`)로 열렸을 때만 — 닫히거나 복사가 성공할 때 `codeView.clearSelectedLines()`로 그 선택을 해제한다(`onGutterUtilityClick`이 세우는 `grabOwnsLineSelection` 플래그를 `onCopied`/`onClosed`가 공통으로 가드). 텍스트 드래그 경로는 네이티브 브라우저 Selection만 읽을 뿐 이 슬롯을 건드리지 않으므로 플래그를 세우지 않는다 — 무조건 해제했다면 find 바가 `revealMatch`/`selectMatch`로 같은 슬롯에 세워 둔 매치 하이라이트가 텍스트 경로 팝오버의 Esc나 복사 성공만으로 지워지고, 팝오버를 연 적 없는 "파킹된"(드래그만 하고 아직 "+"를 안 누른) 거터 선택도 `renderPatch`(팝오버가 열렸든 아니든 진입부에서 항상 `close()`를 호출)마다 지워지는 회귀가 생긴다. 거터 경로에서 스테일 선택이 남으면 안 되는 이유: 엔진의 `InteractionManager.placeUtility()`는 활성 선택이 있으면 호버를 무시하고 "+"를 선택 하단 행에 고정하며(그 행이 더 이상 렌더 대상이 아니면 아예 숨김) 이후 다른 행 호버에서도 "+"가 뜨지 않게 된다. 텍스트 경로 팝오버가 열리면(`input.focus()`) 네이티브 드래그 선택은 붕괴한다 — 포커스가 문서 선택을 팝오버 input으로 **옮기는** 것이라(실측: `getComposedRanges`가 `#grab-popover`의 자식을 가리킨다) 페인팅만 멈추는 게 아니고, 강제로 되돌려도 첫 타이핑에 다시 붕괴한다. 그래서 잡은 라인은 **grab 하이라이트**가 대신 보여준다: `unsafeCSS`로 넣은 `::highlight(diffdeck-grab)`(파랑) + `CSS.highlights` 레지스트리로 칠하는 독립 채널이다. 앱이 shadow root에 직접 `