fix(suggestion): place overlay in DIPs, size it to content, and harden suggestion sessions
This commit is contained in:
parent
75e053d72f
commit
9e5b94ced2
18 changed files with 2178 additions and 558 deletions
150
packages/core/__tests__/popup-placement.test.ts
Normal file
150
packages/core/__tests__/popup-placement.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
anchorFloatingPanel,
|
||||
asDipPoint,
|
||||
asDipRect,
|
||||
fitAnchoredPanel,
|
||||
placeAnchoredPanel,
|
||||
placeNearCursor,
|
||||
type DipPoint,
|
||||
type DipRect,
|
||||
type PhysicalRect,
|
||||
type ScreenGeometryPort,
|
||||
type UiRect
|
||||
} from '../src/popup-placement'
|
||||
|
||||
/**
|
||||
* 두 모니터 가짜 포트 — 물리 픽셀 좌표계에서 모니터마다 배율이 다르다.
|
||||
* A: 물리 (0,0) 1920x1080 @150% → DIP (0,0) 1280x720 (작업 영역 높이 680)
|
||||
* B: 물리 (1920,0) 1920x1080 @100% → DIP (1280,0) 1920x1080 (작업 영역 높이 1040)
|
||||
*/
|
||||
function twoMonitorPort(cursor: { x: number; y: number } = { x: 10, y: 10 }): ScreenGeometryPort {
|
||||
const monitors = [
|
||||
{ physical: { x: 0, y: 0, width: 1920, height: 1080 }, dip: { x: 0, y: 0, width: 1280, height: 720 }, scale: 1.5, workHeight: 680 },
|
||||
{ physical: { x: 1920, y: 0, width: 1920, height: 1080 }, dip: { x: 1280, y: 0, width: 1920, height: 1080 }, scale: 1, workHeight: 1040 }
|
||||
]
|
||||
const monitorOfPhysical = (rect: UiRect) =>
|
||||
monitors.find((m) => rect.x >= m.physical.x && rect.x < m.physical.x + m.physical.width) ?? monitors[0]
|
||||
const monitorOfDip = (point: { x: number; y: number }) =>
|
||||
monitors.find((m) => point.x >= m.dip.x && point.x < m.dip.x + m.dip.width) ?? monitors[0]
|
||||
return {
|
||||
toDip(rect: PhysicalRect): DipRect {
|
||||
const m = monitorOfPhysical(rect)
|
||||
return asDipRect({
|
||||
x: Math.round(m.dip.x + (rect.x - m.physical.x) / m.scale),
|
||||
y: Math.round(m.dip.y + (rect.y - m.physical.y) / m.scale),
|
||||
width: Math.round(rect.width / m.scale),
|
||||
height: Math.round(rect.height / m.scale)
|
||||
})
|
||||
},
|
||||
workAreaNear(point: DipPoint): DipRect {
|
||||
const m = monitorOfDip(point)
|
||||
return asDipRect({ x: m.dip.x, y: m.dip.y, width: m.dip.width, height: m.workHeight })
|
||||
},
|
||||
cursor: () => asDipPoint(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
const SIZE = { width: 460, height: 208 }
|
||||
|
||||
describe('placeAnchoredPanel — 물리 픽셀 앵커를 DIP 로 한 번만 바꾼다', () => {
|
||||
it('150% 모니터에서 캐럿 (600,400) 물리 → DIP (400,267) 바로 아래에 붙는다', () => {
|
||||
const caret: PhysicalRect = { x: 600, y: 400, width: 2, height: 30 }
|
||||
const placement = placeAnchoredPanel(twoMonitorPort(), caret, 'caret', SIZE)
|
||||
// DIP 캐럿: x=400, y=267, h=20 → 아래 6px
|
||||
expect(placement).toMatchObject({ x: 400, y: 267 + 20 + 6, grow: 'down', maxHeight: 208, width: 460 })
|
||||
})
|
||||
|
||||
it('150% 모니터에서 캐럿 (1500,900) 물리는 작업 영역 밖으로 읽히지 않고 캐럿 위로 뒤집힌다', () => {
|
||||
const caret: PhysicalRect = { x: 1500, y: 900, width: 2, height: 30 }
|
||||
const placement = placeAnchoredPanel(twoMonitorPort(), caret, 'caret', SIZE)
|
||||
// DIP 캐럿: x=1000, y=600, h=20 → 아래는 680 을 넘으므로 위(600-6-208=386)
|
||||
expect(placement.y).toBe(386)
|
||||
expect(placement.grow).toBe('up')
|
||||
// 가로는 작업 영역 오른쪽(1280-460=820)으로 클램프될 뿐 모서리 구석에 박히지 않는다
|
||||
expect(placement.x).toBe(820)
|
||||
})
|
||||
|
||||
it('두 번째(100%) 모니터의 캐럿은 그 모니터의 작업 영역을 쓴다', () => {
|
||||
const caret: PhysicalRect = { x: 2000, y: 900, width: 2, height: 20 }
|
||||
const placement = placeAnchoredPanel(twoMonitorPort(), caret, 'caret', SIZE)
|
||||
// DIP: x = 1280 + 80 = 1360, y = 900 → 아래(926+208=1134 > 1040) 불가 → 위 686
|
||||
expect(placement).toMatchObject({ x: 1360, y: 900 - 6 - 208, grow: 'up' })
|
||||
})
|
||||
|
||||
it('앵커가 없으면 DIP 커서 기준이다', () => {
|
||||
const placement = placeAnchoredPanel(twoMonitorPort({ x: 100, y: 100 }), null, null, SIZE)
|
||||
expect(placement).toMatchObject({ x: 100, y: 106, grow: 'down' })
|
||||
})
|
||||
|
||||
it('100% 배율(항등 포트)에서는 기존 anchorFloatingPanel 결과와 같다', () => {
|
||||
const identity: ScreenGeometryPort = {
|
||||
toDip: (rect) => asDipRect(rect),
|
||||
workAreaNear: () => asDipRect({ x: 0, y: 0, width: 1920, height: 1040 }),
|
||||
cursor: () => asDipPoint({ x: 5, y: 5 })
|
||||
}
|
||||
const caret = { x: 700, y: 500, width: 2, height: 18 }
|
||||
const placement = placeAnchoredPanel(identity, caret, 'caret', SIZE)
|
||||
const legacy = anchorFloatingPanel(caret, 'caret', { x: 5, y: 5 }, SIZE, { x: 0, y: 0, width: 1920, height: 1040 })
|
||||
expect({ x: placement.x, y: placement.y }).toEqual(legacy)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fitAnchoredPanel — 창을 그려진 패널 높이로 줄인다', () => {
|
||||
it('아래로 붙은 패널은 위 모서리를 고정한다', () => {
|
||||
expect(fitAnchoredPanel({ x: 10, y: 100, width: 460, maxHeight: 208, grow: 'down' }, 54)).toEqual({
|
||||
x: 10,
|
||||
y: 100,
|
||||
width: 460,
|
||||
height: 54
|
||||
})
|
||||
})
|
||||
|
||||
it('위로 뒤집힌 패널은 앵커 쪽(아래) 모서리를 고정한다', () => {
|
||||
expect(fitAnchoredPanel({ x: 10, y: 386, width: 460, maxHeight: 208, grow: 'up' }, 54)).toEqual({
|
||||
x: 10,
|
||||
y: 386 + 208 - 54,
|
||||
width: 460,
|
||||
height: 54
|
||||
})
|
||||
})
|
||||
|
||||
it('최대 높이를 넘지 않는다', () => {
|
||||
expect(fitAnchoredPanel({ x: 0, y: 0, width: 460, maxHeight: 208, grow: 'down' }, 500).height).toBe(208)
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeNearCursor — 커서 기준 팝업 배치', () => {
|
||||
const workArea = { x: 0, y: 0, width: 1366, height: 728 }
|
||||
|
||||
it('커서 위 20px, 가로 가운데', () => {
|
||||
expect(placeNearCursor({ x: 600, y: 500 }, { width: 340, height: 200 }, workArea)).toEqual({ x: 430, y: 280 })
|
||||
})
|
||||
|
||||
it('위 공간이 없으면 아래로 뒤집는다', () => {
|
||||
expect(placeNearCursor({ x: 600, y: 100 }, { width: 340, height: 200 }, workArea)).toEqual({ x: 430, y: 120 })
|
||||
})
|
||||
|
||||
it('아래로 뒤집힌 큰 팝업도 작업 영역 아래로 잘리지 않는다 (기록/명령 팝업 회귀)', () => {
|
||||
const position = placeNearCursor({ x: 600, y: 300 }, { width: 380, height: 500 }, workArea)
|
||||
expect(position.y + 500).toBeLessThanOrEqual(728)
|
||||
expect(position.y).toBe(228)
|
||||
})
|
||||
|
||||
it('왼쪽/오른쪽 가장자리에서 클램프한다', () => {
|
||||
expect(placeNearCursor({ x: 5, y: 500 }, { width: 340, height: 100 }, workArea).x).toBe(0)
|
||||
expect(placeNearCursor({ x: 1360, y: 500 }, { width: 340, height: 100 }, workArea).x).toBe(1366 - 340)
|
||||
})
|
||||
|
||||
it('음수 좌표 모니터(주 모니터 왼쪽)에서도 그 작업 영역 안에 둔다', () => {
|
||||
const left = { x: -1920, y: -200, width: 1920, height: 1040 }
|
||||
expect(placeNearCursor({ x: -1910, y: -150 }, { width: 320, height: 110 }, left)).toEqual({ x: -1920, y: -130 })
|
||||
})
|
||||
|
||||
it('팝업이 작업 영역보다 크면 왼쪽 위 모서리에 둔다', () => {
|
||||
expect(placeNearCursor({ x: 50, y: 50 }, { width: 400, height: 300 }, { x: 0, y: 0, width: 300, height: 200 })).toEqual({
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue