diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/index.vue b/apps/frontend/src/components/analytics-dashboard/analytics-chart/index.vue index 2011888a655..1de47a074a2 100644 --- a/apps/frontend/src/components/analytics-dashboard/analytics-chart/index.vue +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/index.vue @@ -80,7 +80,7 @@
- +
@@ -98,14 +98,13 @@ diff --git a/apps/frontend/src/components/project-review/layout/context.ts b/apps/frontend/src/components/project-review/layout/context.ts index be4febb17ab..04e467a3aec 100644 --- a/apps/frontend/src/components/project-review/layout/context.ts +++ b/apps/frontend/src/components/project-review/layout/context.ts @@ -13,3 +13,8 @@ export const [injectProjectReviewContext, provideProjectReviewContext] = export const [injectReviewSlot, provideReviewSlot] = createContext>('ProjectReviewSlot') + +/** Whether the surrounding panel is currently shown, e.g. it is the active tab in its group. */ +export const [injectReviewPanelVisible, provideReviewPanelVisible] = createContext< + Readonly> +>('ProjectReviewPanelVisible') diff --git a/apps/frontend/src/components/project-review/layout/drag-popout.ts b/apps/frontend/src/components/project-review/layout/drag-popout.ts index d31b418d4f3..115bf5c0797 100644 --- a/apps/frontend/src/components/project-review/layout/drag-popout.ts +++ b/apps/frontend/src/components/project-review/layout/drag-popout.ts @@ -7,9 +7,6 @@ import type { import type { ReviewPopoutTarget } from './popout-window' -type WindowOrigin = { left: number; top: number; width: number; height: number } -type DropTarget = { element: Element; clientX: number; clientY: number } - export function registerDragPopouts( api: DockviewApi, popout: ( @@ -18,232 +15,139 @@ export function registerDragPopouts( sourceWindow: Window, ) => void, ): DockviewIDisposable { - const windowCleanups = new Map void>() - const origins = new Map() - const windowOrder = new Map() - let focusSequence = 0 let clearDrag: (() => void) | undefined - let cancelDrag: (() => void) | undefined - - function trackWindows() { - const windows = new Set([window, ...api.getPopouts().map((entry) => entry.window)]) - for (const [child, cleanup] of windowCleanups) { - if (windows.has(child)) continue - cleanup() - windowCleanups.delete(child) - origins.delete(child) - windowOrder.delete(child) - } - for (const child of windows) { - if (windowCleanups.has(child)) continue - function recordOrigin(event: PointerEvent) { - origins.set(child, { - left: event.screenX - event.clientX - child.screenX, - top: event.screenY - event.clientY - child.screenY, - width: child.innerWidth, - height: child.innerHeight, - }) - } - function onFocus() { - windowOrder.set(child, ++focusSequence) - } - function onKeydown(event: KeyboardEvent) { - if (event.key !== 'Escape' || !cancelDrag) return - event.preventDefault() - event.stopImmediatePropagation() - cancelDrag() - } - child.addEventListener('pointermove', recordOrigin, true) - child.addEventListener('pointerdown', recordOrigin, true) - child.addEventListener('focus', onFocus) - child.addEventListener('keydown', onKeydown, true) - windowCleanups.set(child, () => { - child.removeEventListener('pointermove', recordOrigin, true) - child.removeEventListener('pointerdown', recordOrigin, true) - child.removeEventListener('focus', onFocus) - child.removeEventListener('keydown', onKeydown, true) - }) - } - } - - function windowPoint(child: Window, event: PointerEvent) { - const origin = origins.get(child) - const border = Math.max(0, (child.outerWidth - child.innerWidth) / 2) - const calibrated = origin?.width === child.innerWidth && origin.height === child.innerHeight - return { - clientX: event.screenX - child.screenX - (calibrated ? origin.left : border), - clientY: - event.screenY - - child.screenY - - (calibrated ? origin.top : Math.max(0, child.outerHeight - child.innerHeight - border)), - } - } - - function findDropTarget(sourceWindow: Window, event: PointerEvent): DropTarget | undefined { - const windows = [...windowCleanups.keys()].sort( - (a, b) => (windowOrder.get(b) ?? 0) - (windowOrder.get(a) ?? 0), - ) - for (const child of windows) { - if (child.closed) continue - const point = child === sourceWindow ? event : windowPoint(child, event) - if ( - point.clientX < 0 || - point.clientY < 0 || - point.clientX >= child.innerWidth || - point.clientY >= child.innerHeight - ) - continue - if (child === sourceWindow) return - const element = child.document.elementFromPoint(point.clientX, point.clientY) - if (!element?.closest('.project-review-tabs, .dv-popout-window')) return - return { element, clientX: point.clientX, clientY: point.clientY } - } - } function onDragStart( target: ReviewPopoutTarget, group: DockviewGroupPanel, nativeEvent: DragEvent | PointerEvent, ) { - cancelDrag?.() - if (!('pointerId' in nativeEvent)) return - const source = nativeEvent.target as HTMLElement | null - const ownerWindow = source?.ownerDocument.defaultView + clearDrag?.() + if ('pointerId' in nativeEvent) return + const eventTarget = nativeEvent.target as HTMLElement | null + const ownerWindow = eventTarget?.ownerDocument.defaultView const dock = group.element.closest('.dv-popout-window, .project-review-tabs') - if (!source || !ownerWindow || !dock) return + if (!eventTarget || !ownerWindow || !dock) return + const source = eventTarget const sourceWindow = ownerWindow - const captureElement = source.ownerDocument.documentElement - const pointerId = nativeEvent.pointerId const bounds = dock.getBoundingClientRect() + const windowOrigin = { + left: nativeEvent.screenX - nativeEvent.clientX - sourceWindow.screenX, + top: nativeEvent.screenY - nativeEvent.clientY - sourceWindow.screenY, + } const panelIds = new Set( 'panels' in target ? target.panels.map((panel) => panel.id) : [target.id], ) - let remoteTarget: DropTarget | undefined - let lastEvent = nativeEvent + const windows = [window, ...api.getPopouts().map((entry) => entry.window)] + let dropCleanupTimer: ReturnType | undefined + let nativeDrop: DragEvent | undefined let moved = false let dropped = false + let cancelled = false const movedSubscription = api.onDidMovePanel((event) => { if (panelIds.has(event.panel.id)) moved = true }) const dropSubscription = api.onDidDrop(() => { dropped = true }) - windowOrder.set(sourceWindow, ++focusSequence) - /** Reuse Dockview's drop targets and shared panel transfer across documents. */ - function dispatchDropEvent(type: string, destination: DropTarget, event: PointerEvent) { - destination.element.dispatchEvent( - new DragEvent(type, { - bubbles: true, - cancelable: true, - view: destination.element.ownerDocument.defaultView, - clientX: destination.clientX, - clientY: destination.clientY, - screenX: event.screenX, - screenY: event.screenY, - ctrlKey: event.ctrlKey, - shiftKey: event.shiftKey, - altKey: event.altKey, - metaKey: event.metaKey, - }), + function openPopout(event: DragEvent) { + popout( + target, + { + left: event.screenX, + top: event.screenY, + width: Math.max(400, bounds.width), + height: Math.max(300, bounds.height), + }, + sourceWindow, ) } - function clearRemoteTarget() { - if (remoteTarget) dispatchDropEvent('dragleave', remoteTarget, lastEvent) - remoteTarget = undefined + function isWorkspaceBackground(event: DragEvent) { + const element = event.target as Element | null + return ( + !!element?.closest('.project-review') && + !element.closest( + '.project-review-tabs, .dv-popout-window, .project-review-dock, .dv-drop-target, .dv-drop-target-container', + ) + ) } - function updateRemoteTarget(event: PointerEvent) { - const destination = findDropTarget(sourceWindow, event) - if (destination?.element !== remoteTarget?.element) { - clearRemoteTarget() - if (destination) dispatchDropEvent('dragenter', destination, event) - } - remoteTarget = destination - if (destination) dispatchDropEvent('dragover', destination, event) + function onDragOver(event: DragEvent) { + if (event.defaultPrevented || !isWorkspaceBackground(event)) return + event.preventDefault() + if (event.dataTransfer) event.dataTransfer.dropEffect = 'move' } - function cancelDockview() { - sourceWindow.dispatchEvent(new PointerEvent('pointercancel', { pointerId })) + function observeDrop(event: DragEvent) { + nativeDrop = event + dropCleanupTimer = setTimeout(() => onDragEnd(event), 0) } - function onPointerMove(event: PointerEvent) { - if (event.pointerId !== pointerId) return - lastEvent = event - updateRemoteTarget(event) + function onBackgroundDrop(event: DragEvent) { + if (event.defaultPrevented || cancelled || moved || !isWorkspaceBackground(event)) return + event.preventDefault() + dropped = true + openPopout(event) } - function onPointerUp(event: PointerEvent) { - if (event.pointerId !== pointerId || !clearDrag) return - lastEvent = event - updateRemoteTarget(event) + function onDragEnd(event: DragEvent) { + const release = nativeDrop ?? event + const clientX = release.screenX - sourceWindow.screenX - windowOrigin.left + const clientY = release.screenY - sourceWindow.screenY - windowOrigin.top + const destination = (nativeDrop?.target as Node | null)?.ownerDocument const outside = - event.clientX < bounds.left || - event.clientX > bounds.right || - event.clientY < bounds.top || - event.clientY > bounds.bottom - if (!remoteTarget && !outside) { - queueMicrotask(() => clearDrag?.()) - return - } - event.stopImmediatePropagation() - if (remoteTarget) dispatchDropEvent('drop', remoteTarget, event) - const shouldPopout = !moved && !dropped + (destination && destination !== source.ownerDocument) || + clientX < bounds.left || + clientX > bounds.right || + clientY < bounds.top || + clientY > bounds.bottom + const shouldPopout = + !cancelled && + !moved && + !dropped && + (release.screenX !== 0 || release.screenY !== 0) && + outside clearDrag?.() - cancelDockview() - if (shouldPopout) { - popout( - target, - { - left: event.screenX, - top: event.screenY, - width: Math.max(400, bounds.width), - height: Math.max(300, bounds.height), - }, - sourceWindow, - ) - } + if (shouldPopout) openPopout(release) } - function onPointerCancel(event: PointerEvent) { - if (event.pointerId === pointerId) cancelDrag?.() + function onKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') cancelled = true } function onWindowClose() { - cancelDrag?.() + cancelled = true + clearDrag?.() } - clearDrag = () => { - clearDrag = undefined - cancelDrag = undefined - sourceWindow.removeEventListener('pointermove', onPointerMove, true) - sourceWindow.removeEventListener('pointerup', onPointerUp, true) - sourceWindow.removeEventListener('pointercancel', onPointerCancel, true) - sourceWindow.removeEventListener('lostpointercapture', onPointerCancel, true) - sourceWindow.removeEventListener('beforeunload', onWindowClose) - clearRemoteTarget() + function cleanup() { + if (clearDrag === cleanup) clearDrag = undefined + clearTimeout(dropCleanupTimer) + source.removeEventListener('dragend', onDragEnd) + for (const child of windows) { + child.removeEventListener('dragover', onDragOver) + child.removeEventListener('drop', observeDrop, true) + child.removeEventListener('drop', onBackgroundDrop) + child.removeEventListener('keydown', onKeydown, true) + child.removeEventListener('beforeunload', onWindowClose) + } movedSubscription.dispose() dropSubscription.dispose() - if (captureElement.hasPointerCapture(pointerId)) - captureElement.releasePointerCapture(pointerId) } - cancelDrag = () => { - clearDrag?.() - cancelDockview() + clearDrag = cleanup + source.addEventListener('dragend', onDragEnd) + for (const child of windows) { + child.addEventListener('dragover', onDragOver) + child.addEventListener('drop', observeDrop, { capture: true, passive: true }) + child.addEventListener('drop', onBackgroundDrop) + child.addEventListener('keydown', onKeydown, true) + child.addEventListener('beforeunload', onWindowClose) } - sourceWindow.addEventListener('pointermove', onPointerMove, true) - sourceWindow.addEventListener('pointerup', onPointerUp, true) - sourceWindow.addEventListener('pointercancel', onPointerCancel, true) - sourceWindow.addEventListener('lostpointercapture', onPointerCancel, true) - sourceWindow.addEventListener('beforeunload', onWindowClose) - captureElement.setPointerCapture(pointerId) } - trackWindows() const subscriptions = [ - api.onDidAddPopoutGroup(trackWindows), - api.onDidRemovePopoutGroup(trackWindows), api.onWillDragPanel(({ panel, nativeEvent }) => { onDragStart(panel, panel.group, nativeEvent) }), @@ -253,8 +157,7 @@ export function registerDragPopouts( ] return { dispose() { - cancelDrag?.() - windowCleanups.forEach((cleanup) => cleanup()) + clearDrag?.() subscriptions.forEach((subscription) => subscription.dispose()) }, } diff --git a/apps/frontend/src/components/project-review/layout/index.client.vue b/apps/frontend/src/components/project-review/layout/index.client.vue index 609b8c48922..c4d2500cecd 100644 --- a/apps/frontend/src/components/project-review/layout/index.client.vue +++ b/apps/frontend/src/components/project-review/layout/index.client.vue @@ -78,7 +78,6 @@ provideProjectReviewContext({ ...layout, slots }) useReviewShortcut('toggle-left', () => layout.toggleSidebar('left')) useReviewShortcut('toggle-right', () => layout.toggleSidebar('right')) useReviewShortcut('reveal-right', () => layout.revealSlot('right')) -useReviewShortcut('toggle-bottom', layout.toggleToolsPanel) const keybinds = useModerationKeybinds() useReviewEventListener('keydown', (event) => { diff --git a/apps/frontend/src/components/project-review/layout/layout-storage.ts b/apps/frontend/src/components/project-review/layout/layout-storage.ts index 86a9c8e4aa5..f2a217fcbc6 100644 --- a/apps/frontend/src/components/project-review/layout/layout-storage.ts +++ b/apps/frontend/src/components/project-review/layout/layout-storage.ts @@ -7,16 +7,12 @@ const STORAGE_KEY = 'moderation.project-review.layout.v1' export const workspacePanelSizes = { left: { default: 400, minimum: 200 }, right: { default: 400, minimum: 200 }, - bottom: { default: 200, minimum: 50 }, center: { minimum: 20 }, - tabs: { minimum: 100 }, } as const export interface SavedWorkspaceLayout { leftWidth: number rightWidth: number - bottomHeight: number - bottomVisible?: boolean leftVisible: boolean rightVisible: boolean tabs: SerializedDockview @@ -64,10 +60,9 @@ export function readWorkspaceLayout(): SavedWorkspaceLayout | undefined { ) if ( !saved || - ![saved.leftWidth, saved.rightWidth, saved.bottomHeight].every( + ![saved.leftWidth, saved.rightWidth].every( (size) => typeof size === 'number' && Number.isFinite(size) && size > 0, ) || - (saved.bottomVisible !== undefined && typeof saved.bottomVisible !== 'boolean') || typeof saved.leftVisible !== 'boolean' || typeof saved.rightVisible !== 'boolean' || !isValidTabLayout(saved.tabs) || diff --git a/apps/frontend/src/components/project-review/layout/panel.vue b/apps/frontend/src/components/project-review/layout/panel.vue index 39d8aa157fd..0d021a35e66 100644 --- a/apps/frontend/src/components/project-review/layout/panel.vue +++ b/apps/frontend/src/components/project-review/layout/panel.vue @@ -5,7 +5,8 @@ slotName === 'right' || slotName === 'disclosures' || slotName === 'permissions' || - slotName === 'gallery' + slotName === 'gallery' || + slotName === 'slicer' ? 'overflow-hidden' : 'overflow-auto' " @@ -25,10 +26,10 @@ diff --git a/apps/frontend/src/components/project-review/layout/tabs/tabs.vue b/apps/frontend/src/components/project-review/layout/tabs/tabs.vue index 2286891c673..b371b8522b4 100644 --- a/apps/frontend/src/components/project-review/layout/tabs/tabs.vue +++ b/apps/frontend/src/components/project-review/layout/tabs/tabs.vue @@ -1,9 +1,9 @@