Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ Please fill out the template completely to avoid delays in review.

## 📸 Screenshots / Visual Evidence

<!-- Drop your screenshots or screen recordings (GIF/MP4) here -->
<!-- Upload screenshots or screen recordings directly in the GitHub PR UI. Do not commit review-only media into the repo unless it is a maintained visual-regression baseline. -->
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Spaces: warn before closing the last node in a space when it would become empty and auto-close, using the shared warning dialog shell. (#66)

### 🐞 Fixed
- UI: unified shared menu overlays to fix prompt template, task session, and related context-menu offset issues. (#121)
- Persistence: Repair cumulative SQLite schema upgrades and auto-heal mis-versioned local databases so workspace state saves no longer fail after upgrading from older installs. (#76)
- Spaces: New windows created from a crowded space now preserve existing window layout, expand the space only as needed, and keep the viewport centered on the final position. (#62)
- Settings: update status now summarizes updater feed parsing errors instead of dumping raw parser/CSP output. (#67)
Expand Down
2 changes: 2 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@
- `pnpm pre-commit` 会执行 `pnpm naming-check:staged`:禁止在新代码里重新引入 `cove:*`(对外/协议/持久化),仅允许显式 legacy 迁移用途;UI 设计系统前缀仍保留 `cove`(见上文命名约定)。
- 若本次改动包含用户可感知变化(新增功能、UX 改动、修复 bug、默认行为变化),应先提交代码并创建 PR;拿到 PR 链接/编号后,再更新 `CHANGELOG.md` 的 `## [Unreleased]` 并单独提交(每个变化一条,尽量附 `#PR` 编号)。`nightly` tag 不要求更新 changelog;发 `stable` 时再把 `Unreleased` 结算进新版本段。
- 创建/更新 PR 时:若本次改动包含用户可感知变化,必须跑 Playwright E2E(通常 `pnpm test:e2e`,或统一跑 `pnpm pre-commit`)。
- 创建/更新 PR 时:必须按 `.github/pull_request_template.md` 的结构完整填写;若使用 `gh pr create` / `gh pr edit`,也要显式按模板组织 title/body,不得跳过必填段落。
- PR 中的截图 / 录屏应通过 GitHub PR 描述或评论框直接上传,不要把这类评审素材作为 repo 资产提交;只有明确作为视觉回归基线的 snapshot / golden 文件,才应进入仓库并纳入测试。
- 若本次改动涉及 **Renderer 用户可见文案**,必须做好 i18n:禁止新增硬编码用户文案,新增/修改文案时同步更新 `src/app/renderer/i18n/locales/en.ts` 与 `src/app/renderer/i18n/locales/zh-CN.ts`,并在提交前做一次对应语言的最小 smoke/测试验证。
- 通过上述检查后,再执行 `pnpm pre-commit` (type, lint, format, test)。
- **测试失败排查前置**:
Expand Down
235 changes: 235 additions & 0 deletions src/app/renderer/components/ViewportMenuSurface.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import React from 'react'
import { createPortal } from 'react-dom'
import {
placeViewportMenuAtPoint,
type MenuPoint,
type MenuPointAlignment,
type MenuSize,
} from './viewportMenuPlacement'

interface AbsoluteViewportMenuPlacement {
type: 'absolute'
left: number
top: number
}

interface PointViewportMenuPlacement {
type: 'point'
point: MenuPoint
alignX?: MenuPointAlignment
alignY?: MenuPointAlignment
padding?: number
estimatedSize?: MenuSize
}

export type ViewportMenuPlacement = AbsoluteViewportMenuPlacement | PointViewportMenuPlacement

export interface ViewportMenuSurfaceProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
'children'
> {
open: boolean
placement: ViewportMenuPlacement
children: React.ReactNode
onDismiss?: () => void
dismissOnPointerDownOutside?: boolean
dismissOnEscape?: boolean
dismissIgnoreRefs?: Array<React.RefObject<HTMLElement | null>>
stopEventPropagation?: boolean
}

function assignRef<T>(ref: React.ForwardedRef<T>, value: T): void {
if (typeof ref === 'function') {
ref(value)
return
}

if (ref) {
ref.current = value
}
}

function callHandler<E extends React.SyntheticEvent>(
handler: ((event: E) => void) | undefined,
event: E,
): void {
handler?.(event)
}

export const ViewportMenuSurface = React.forwardRef<HTMLDivElement, ViewportMenuSurfaceProps>(
function ViewportMenuSurface(
{
open,
placement,
children,
onDismiss,
dismissOnPointerDownOutside = false,
dismissOnEscape = false,
dismissIgnoreRefs = [],
stopEventPropagation = true,
style,
onMouseDown,
onClick,
...rest
},
forwardedRef,
): React.JSX.Element | null {
const surfaceRef = React.useRef<HTMLDivElement | null>(null)
const [measuredSize, setMeasuredSize] = React.useState<MenuSize | null>(null)

const setRefs = React.useCallback(
(node: HTMLDivElement | null) => {
surfaceRef.current = node
assignRef(forwardedRef, node)
},
[forwardedRef],
)

React.useLayoutEffect(() => {
if (!open) {
setMeasuredSize(null)
return
}

if (placement.type !== 'point') {
return
}

const element = surfaceRef.current
if (!element) {
setMeasuredSize(null)
return
}

const updateMeasuredSize = (): void => {
const rect = element.getBoundingClientRect()
setMeasuredSize(previous =>
previous !== null &&
Math.abs(previous.width - rect.width) < 0.5 &&
Math.abs(previous.height - rect.height) < 0.5
? previous
: { width: rect.width, height: rect.height },
)
}

updateMeasuredSize()

if (typeof ResizeObserver === 'undefined') {
return
}

const observer = new ResizeObserver(() => {
updateMeasuredSize()
})
observer.observe(element)

return () => {
observer.disconnect()
}
}, [open, placement])

React.useEffect(() => {
if (!open) {
return
}

if (!onDismiss || (!dismissOnPointerDownOutside && !dismissOnEscape)) {
return
}

const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Node)) {
return false
}

if (surfaceRef.current?.contains(target)) {
return true
}

return dismissIgnoreRefs.some(ref => ref.current?.contains(target) ?? false)
}

const handlePointerDown = (event: PointerEvent): void => {
if (!dismissOnPointerDownOutside) {
return
}

if (shouldIgnoreTarget(event.target)) {
return
}

onDismiss()
}

const handleKeyDown = (event: KeyboardEvent): void => {
if (!dismissOnEscape || event.key !== 'Escape') {
return
}

onDismiss()
}

document.addEventListener('pointerdown', handlePointerDown, true)
window.addEventListener('keydown', handleKeyDown, true)

return () => {
document.removeEventListener('pointerdown', handlePointerDown, true)
window.removeEventListener('keydown', handleKeyDown, true)
}
}, [dismissIgnoreRefs, dismissOnEscape, dismissOnPointerDownOutside, onDismiss, open])

const resolvedPosition = React.useMemo(() => {
if (placement.type === 'absolute') {
return {
left: placement.left,
top: placement.top,
}
}

const viewportWidth = typeof window === 'undefined' ? 1280 : window.innerWidth
const viewportHeight = typeof window === 'undefined' ? 720 : window.innerHeight

return placeViewportMenuAtPoint({
point: placement.point,
menuSize: measuredSize ?? placement.estimatedSize ?? { width: 0, height: 0 },
viewport: { width: viewportWidth, height: viewportHeight },
padding: placement.padding,
alignX: placement.alignX,
alignY: placement.alignY,
})
}, [measuredSize, placement])

if (!open || typeof document === 'undefined' || !document.body) {
return null
}

return createPortal(
<div
{...rest}
ref={setRefs}
style={{
...style,
top: resolvedPosition.top,
left: resolvedPosition.left,
}}
onMouseDown={event => {
if (stopEventPropagation) {
event.stopPropagation()
}

callHandler(onMouseDown, event)
}}
onClick={event => {
if (stopEventPropagation) {
event.stopPropagation()
}

callHandler(onClick, event)
}}
>
{children}
</div>,
document.body,
)
},
)
Loading
Loading