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 @@
- ]
+
VNode[]
}
diff --git a/apps/frontend/src/components/project-review/layout/use-layout.ts b/apps/frontend/src/components/project-review/layout/use-layout.ts
index a8ad3d1acad..52d6c962967 100644
--- a/apps/frontend/src/components/project-review/layout/use-layout.ts
+++ b/apps/frontend/src/components/project-review/layout/use-layout.ts
@@ -42,15 +42,12 @@ export function useProjectReviewLayout(
const savedLayout = readWorkspaceLayout()
const leftVisible = ref(savedLayout?.leftVisible ?? true)
const rightVisible = ref(savedLayout?.rightVisible ?? true)
- const bottomVisible = ref(savedLayout?.bottomVisible ?? true)
let leftWidth = savedLayout?.leftWidth ?? workspacePanelSizes.left.default
let rightWidth = savedLayout?.rightWidth ?? workspacePanelSizes.right.default
const visibleRightWidth = ref(rightVisible.value ? rightWidth : 0)
- let bottomHeight = savedLayout?.bottomHeight ?? workspacePanelSizes.bottom.default
const topLeftGroupId = ref()
const topRightGroupId = ref()
let columns: SplitviewApi | undefined
- let rows: SplitviewApi | undefined
let tabsReady = false
let saveTimer: ReturnType | undefined
let tabs: DockviewApi | undefined
@@ -84,20 +81,16 @@ export function useProjectReviewLayout(
function saveLayout() {
clearTimeout(saveTimer)
- if (!columns || !rows || !tabs || !tabsReady) return
+ if (!columns || !tabs || !tabsReady) return
const currentLeftWidth = columns.getPanel('left')?.api.width ?? 0
const currentRightWidth = columns.getPanel('right')?.api.width ?? 0
- const currentBottomHeight = rows.getPanel('bottom')?.api.height ?? 0
if (leftVisible.value && currentLeftWidth > 0) leftWidth = currentLeftWidth
if (rightVisible.value && currentRightWidth > 0) rightWidth = currentRightWidth
- if (bottomVisible.value && currentBottomHeight > 0) bottomHeight = currentBottomHeight
const currentTabs = tabs.toJSON()
if (currentTabLayoutKey) tabLayouts.set(currentTabLayoutKey, currentTabs)
saveWorkspaceLayout({
leftWidth,
rightWidth,
- bottomHeight,
- bottomVisible: bottomVisible.value,
leftVisible: leftVisible.value,
rightVisible: rightVisible.value,
tabs: dockPopoutLayout(currentTabs),
@@ -151,25 +144,6 @@ export function useProjectReviewLayout(
)
}
- function onRowsReady({ api }: SplitviewReadyEvent) {
- rows = api
- api.addPanel({
- id: 'tabs',
- component: 'ProjectReviewTabs',
- minimumSize: workspacePanelSizes.tabs.minimum,
- priority: LayoutPriority.High,
- })
- api.addPanel({
- id: 'bottom',
- component: 'ProjectReviewPanel',
- params: { slot: 'bottom' },
- minimumSize: workspacePanelSizes.bottom.minimum,
- size: bottomHeight,
- })
- api.getPanel('bottom')?.api.setVisible(bottomVisible.value)
- subscriptions.push(api.onDidLayoutChange(scheduleSave))
- }
-
function constrainDrop(event: DockviewWillDropEvent | DockviewWillShowOverlayLocationEvent) {
if (!event.getData()) {
event.preventDefault()
@@ -186,7 +160,8 @@ export function useProjectReviewLayout(
!data ||
data.viewId !== event.api.id ||
event.position === 'center'
- ) return
+ )
+ return
const horizontal = event.position === 'left' || event.position === 'right'
let movedPanel: ReturnType
@@ -285,8 +260,15 @@ export function useProjectReviewLayout(
function openTab(tab: ProjectReviewTab) {
if (!getTabs().includes(tab)) return
const panel = tabs?.getPanel(tab)
- panel?.api.setActive()
- if (panel?.api.location.type === 'popout') panel.api.getWindow().focus()
+ if (!panel) return
+ if (panel.api.location.type === 'popout') {
+ if (!panel.api.isVisible) {
+ panel.group.model.openPanel(panel, { skipSetGroupActive: true })
+ }
+ panel.api.getWindow().focus()
+ return
+ }
+ panel.api.setActive()
}
function popoutTab(
@@ -359,8 +341,6 @@ export function useProjectReviewLayout(
if (slot === 'left' || slot === 'right') {
const visible = slot === 'left' ? leftVisible : rightVisible
if (!visible.value) toggleSidebar(slot)
- } else if (slot === 'bottom') {
- if (!bottomVisible.value) toggleToolsPanel()
} else {
openTab(slot)
}
@@ -391,15 +371,6 @@ export function useProjectReviewLayout(
scheduleSave()
}
- function toggleToolsPanel() {
- const panel = rows?.getPanel('bottom')
- if (!panel || !rows) return
- if (bottomVisible.value) bottomHeight = panel.api.height
- bottomVisible.value = !bottomVisible.value
- panel.api.setVisible(bottomVisible.value)
- scheduleSave()
- }
-
function onDividerDoubleClick(event: MouseEvent) {
if (!(event.target instanceof HTMLElement)) return
const sash = event.target.closest('.dv-sash:not(.dv-disabled)')
@@ -410,9 +381,6 @@ export function useProjectReviewLayout(
const columnSplit = split
.closest('.project-review-columns')
?.querySelector('.dv-split-view-container')
- const rowSplit = split
- .closest('.project-review-rows')
- ?.querySelector('.dv-split-view-container')
if (split === columnSplit && columns) {
const side = index === 0 ? 'left' : 'right'
const panel = columns.getPanel(side)
@@ -428,16 +396,6 @@ export function useProjectReviewLayout(
),
),
})
- } else if (split === rowSplit && rows) {
- rows.getPanel('bottom')?.api.setSize({
- size: Math.max(
- workspacePanelSizes.bottom.minimum,
- Math.min(
- workspacePanelSizes.bottom.default,
- rows.height - workspacePanelSizes.tabs.minimum,
- ),
- ),
- })
} else if (tabs && split.closest('.project-review-tabs')) {
const branch = split.parentElement
if (!branch?.classList.contains('dv-branch-node')) return
@@ -552,7 +510,6 @@ export function useProjectReviewLayout(
popoutTab,
dockTab,
poppingOut,
- bottomVisible,
leftVisible,
openTab,
revealSlot,
@@ -562,10 +519,8 @@ export function useProjectReviewLayout(
topLeftGroupId,
topRightGroupId,
onColumnsReady,
- onRowsReady,
onTabsReady,
onDividerDoubleClick,
toggleSidebar,
- toggleToolsPanel,
}
}
diff --git a/apps/frontend/src/components/project-review/messages.ts b/apps/frontend/src/components/project-review/messages.ts
index c598f43e4d7..7df6d6f99c7 100644
--- a/apps/frontend/src/components/project-review/messages.ts
+++ b/apps/frontend/src/components/project-review/messages.ts
@@ -102,10 +102,6 @@ export const projectReviewMessages = defineMessages({
id: 'moderation.project-review.right',
defaultMessage: 'Right panel',
},
- bottom: {
- id: 'moderation.project-review.bottom',
- defaultMessage: 'Tools',
- },
description: {
id: 'moderation.project-review.description',
defaultMessage: 'Description',
@@ -154,6 +150,14 @@ export const projectReviewMessages = defineMessages({
id: 'moderation.project-review.tech-review',
defaultMessage: 'Tech Review',
},
+ slicer: {
+ id: 'moderation.project-review.slicer',
+ defaultMessage: 'Slicer',
+ },
+ slicerOpenExternal: {
+ id: 'moderation.project-review.slicerOpenExternal',
+ defaultMessage: 'Open in Slicer in a new tab',
+ },
showLeft: {
id: 'moderation.project-review.showLeft',
defaultMessage: 'Show project details',
@@ -170,14 +174,6 @@ export const projectReviewMessages = defineMessages({
id: 'moderation.project-review.hideRight',
defaultMessage: 'Hide panel',
},
- showTools: {
- id: 'moderation.project-review.showTools',
- defaultMessage: 'Show tools panel',
- },
- hideTools: {
- id: 'moderation.project-review.hideTools',
- defaultMessage: 'Hide tools panel',
- },
empty: {
id: 'moderation.project-review.empty',
defaultMessage: 'No project selected',
@@ -657,6 +653,10 @@ export const projectReviewMessages = defineMessages({
id: 'moderation.project-review.openSlicer',
defaultMessage: 'Open in Slicer',
},
+ openVersionSlicer: {
+ id: 'moderation.project-review.openVersionSlicer',
+ defaultMessage: 'Open all files in Slicer',
+ },
scanAi: {
id: 'moderation.project-review.scanAi',
defaultMessage: 'Scan for AI-generated images using C2PA info',
diff --git a/apps/frontend/src/components/project-review/queue-bar.vue b/apps/frontend/src/components/project-review/queue-bar.vue
index 3b0e04ee8ce..69373c648fb 100644
--- a/apps/frontend/src/components/project-review/queue-bar.vue
+++ b/apps/frontend/src/components/project-review/queue-bar.vue
@@ -18,22 +18,6 @@
>
-
-
+
[
label: formatMessage(messages.viewC2paInfo),
icon: ScanEyeIcon,
tone: 'orange' as const,
- disabled: versionsQuery.isPending.value || versionsQuery.isError.value || !c2paUrls.value.length,
+ disabled:
+ versionsQuery.isPending.value || versionsQuery.isError.value || !c2paUrls.value.length,
action: () => emit('scan-ai', c2paUrls.value),
},
])
diff --git a/apps/frontend/src/components/project-review/versions/version-card.vue b/apps/frontend/src/components/project-review/versions/version-card.vue
index e928483a76b..0ad8391bd4a 100644
--- a/apps/frontend/src/components/project-review/versions/version-card.vue
+++ b/apps/frontend/src/components/project-review/versions/version-card.vue
@@ -44,6 +44,24 @@
}}
+
+
+
+
{{
formatMessage(fileTypeMessages[file.file_type])
}}
-
-
-
+
+
+
+ {{ option.label }}
+
@@ -306,6 +321,7 @@ import {
ExternalIcon,
MoreVerticalIcon,
ScanEyeIcon,
+ SpinnerIcon,
} from '@modrinth/assets'
import {
Accordion,
@@ -342,6 +358,7 @@ import EditVersionMenu from '~/components/ui/create-project-version/EditVersionM
import { projectQueryOptions } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { injectProjectReviewPageContext } from '~/providers/project-review'
+import { injectReviewSlicer } from '~/providers/project-review/review-slicer'
import type { EditVersionStage } from '~/providers/version/manage-version-modal'
import { projectReviewMessages as messages } from '../messages'
@@ -369,6 +386,15 @@ const formatBytes = useFormatBytes()
const client = injectModrinthClient()
const tags = injectTags(null)
const { project, members } = injectProjectReviewPageContext()
+const slicer = injectReviewSlicer()
+const { loadingFiles: slicerLoading } = slicer
+const slicerRequest = ref<{ id: number; button: string }>()
+const loadingSlicerButton = computed(() => {
+ const request = slicerRequest.value
+ return slicerLoading.value && request?.id === slicer.openRequests.value
+ ? request.button
+ : undefined
+})
const dependenciesOpen = ref(false)
const card = ref()
const dependenciesSection = ref()
@@ -466,8 +492,41 @@ const dependencies = computed(() =>
return typeDifference || dependencyName(a).localeCompare(dependencyName(b))
}),
)
-function fileActions(file: Labrinth.Versions.v3.Version['files'][number]): ButtonMenuOption[] {
- const options: ButtonMenuOption[] = []
+type VersionFile = Labrinth.Versions.v3.Version['files'][number]
+
+function openInSlicer(urls: string[], button: string) {
+ slicer.open(urls)
+ slicerRequest.value = { id: slicer.openRequests.value, button }
+}
+
+function openVersionInSlicer() {
+ openInSlicer(
+ props.version.files
+ .toSorted((a, b) => Number(b.primary) - Number(a.primary))
+ .map(({ url }) => url),
+ 'version',
+ )
+}
+
+function fileActions(file: VersionFile): ButtonMenuOption[] {
+ const options: ButtonMenuOption[] = [
+ {
+ id: 'open-slicer',
+ label: formatMessage(messages.openSlicer),
+ icon: CoffeeIcon,
+ disabled: loadingSlicerButton.value === file.url,
+ action: () => openInSlicer([file.url], file.url),
+ },
+ {
+ id: 'open-slicer-external',
+ type: 'link',
+ label: formatMessage(messages.slicerOpenExternal),
+ icon: ExternalIcon,
+ href: `https://slicer.run/?url=${encodeURIComponent(file.url)}`,
+ target: '_blank',
+ rel: 'noopener',
+ },
+ ]
for (const algorithm of ['sha1', 'sha512'] as const) {
const hash = file.hashes[algorithm]
if (!hash) continue
diff --git a/apps/frontend/src/components/ui/moderation/ProjectC2paScanModal.vue b/apps/frontend/src/components/ui/moderation/ProjectC2paScanModal.vue
index 368df2a35ac..6eef4b7aee6 100644
--- a/apps/frontend/src/components/ui/moderation/ProjectC2paScanModal.vue
+++ b/apps/frontend/src/components/ui/moderation/ProjectC2paScanModal.vue
@@ -1,6 +1,7 @@
@@ -179,6 +183,18 @@ function createSlicerLink(url: string, group: ClassGroup | undefined) {
+
+
+
-import { DownloadIcon, ExternalIcon } from '@modrinth/assets'
-import { ButtonLink, defineMessages, useFormatBytes, useVIntl } from '@modrinth/ui'
+import { CoffeeIcon, DownloadIcon, ExternalIcon } from '@modrinth/assets'
+import { Button, ButtonLink, defineMessages, useFormatBytes, useVIntl } from '@modrinth/ui'
+
+import { injectReviewSlicer } from '~/providers/project-review/review-slicer'
import type { FlattenedFileReport } from './types'
@@ -8,6 +10,7 @@ defineProps<{
file: FlattenedFileReport
}>()
+const slicer = injectReviewSlicer(null)
const formatBytes = useFormatBytes()
const { formatMessage } = useVIntl()
const messages = defineMessages({
@@ -18,6 +21,10 @@ const messages = defineMessages({
},
slicer: { id: 'moderation.tech-review.file.slicer', defaultMessage: 'Slicer' },
openSlicer: { id: 'moderation.tech-review.file.open-slicer', defaultMessage: 'Open in Slicer' },
+ openSlicerTab: {
+ id: 'moderation.tech-review.file.open-slicer-tab',
+ defaultMessage: 'Open in Slicer tab',
+ },
})
@@ -40,6 +47,17 @@ const messages = defineMessages({
>
+
+
+
diff --git a/apps/frontend/src/helpers/thread-issues.ts b/apps/frontend/src/helpers/thread-issues.ts
index 9990e883ff6..8d735d305a3 100644
--- a/apps/frontend/src/helpers/thread-issues.ts
+++ b/apps/frontend/src/helpers/thread-issues.ts
@@ -125,8 +125,7 @@ export function isThreadIssueFacetReadyToAddress(
)
case 'modify_project_disclosure': {
const disclosure = disclosures?.find(
- (disclosure) =>
- disclosure.type === target.value.disclosure_type && !disclosure.deleted_at,
+ (disclosure) => disclosure.type === target.value.disclosure_type && !disclosure.deleted_at,
)
if (!disclosure) return false
const metadata = Object.fromEntries(
@@ -149,8 +148,7 @@ export function isThreadIssueFacetReadyToAddress(
}
case 'modify_project_disclosure_note': {
const disclosure = disclosures?.find(
- (disclosure) =>
- disclosure.type === target.value.disclosure_type && !disclosure.deleted_at,
+ (disclosure) => disclosure.type === target.value.disclosure_type && !disclosure.deleted_at,
)
return (
disclosure !== undefined &&
diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json
index f89485d84dd..d40bb20c085 100644
--- a/apps/frontend/src/locales/en-US/index.json
+++ b/apps/frontend/src/locales/en-US/index.json
@@ -3128,9 +3128,6 @@
"moderation.project-review.backToFiles": {
"message": "Back to files"
},
- "moderation.project-review.bottom": {
- "message": "Tools"
- },
"moderation.project-review.changelog": {
"message": "Changelog"
},
@@ -3311,9 +3308,6 @@
"moderation.project-review.hideRight": {
"message": "Hide panel"
},
- "moderation.project-review.hideTools": {
- "message": "Hide tools panel"
- },
"moderation.project-review.icon": {
"message": "Icon"
},
@@ -3434,6 +3428,9 @@
"moderation.project-review.openSlicer": {
"message": "Open in Slicer"
},
+ "moderation.project-review.openVersionSlicer": {
+ "message": "Open all files in Slicer"
+ },
"moderation.project-review.optional": {
"message": "Optional"
},
@@ -3590,12 +3587,15 @@
"moderation.project-review.showRight": {
"message": "Show panel"
},
- "moderation.project-review.showTools": {
- "message": "Show tools panel"
- },
"moderation.project-review.skip": {
"message": "Skip"
},
+ "moderation.project-review.slicer": {
+ "message": "Slicer"
+ },
+ "moderation.project-review.slicerOpenExternal": {
+ "message": "Open in Slicer in a new tab"
+ },
"moderation.project-review.slug": {
"message": "Slug"
},
@@ -3728,6 +3728,9 @@
"moderation.tech-review.file.open-slicer": {
"message": "Open in Slicer"
},
+ "moderation.tech-review.file.open-slicer-tab": {
+ "message": "Open in Slicer tab"
+ },
"moderation.tech-review.file.slicer": {
"message": "Slicer"
},
diff --git a/apps/frontend/src/providers/project-review/content.ts b/apps/frontend/src/providers/project-review/content.ts
index e101d7cb4d9..c434576e05b 100644
--- a/apps/frontend/src/providers/project-review/content.ts
+++ b/apps/frontend/src/providers/project-review/content.ts
@@ -19,10 +19,37 @@ export function useReviewContent(projectId: Ref) {
})),
)
const versionsQuery = useQuery(
- computed(() => ({
- ...projectQueryOptions.versionsV3(projectId.value, client),
- enabled: !!projectId.value,
- })),
+ computed(() => {
+ const id = projectId.value
+ const options = projectQueryOptions.versionsV3(id, client)
+ return {
+ ...options,
+ enabled: !!id,
+ queryFn: async () => {
+ const [versions, project] = await Promise.all([
+ options.queryFn(),
+ queryClient.fetchQuery(projectQueryOptions.v3(id, client)),
+ ])
+ const loadedIds = new Set(versions.map((version) => version.id))
+ const missingIds = project.versions.filter((versionId) => !loadedIds.has(versionId))
+ /** The list endpoint can transiently omit versions that are still available by ID. */
+ for (let index = 0; index < missingIds.length; index += 100) {
+ const recovered = await client.labrinth.versions_v3.getVersions(
+ missingIds.slice(index, index + 100),
+ )
+ for (const version of recovered) {
+ if (loadedIds.has(version.id)) continue
+ loadedIds.add(version.id)
+ versions.push(version)
+ }
+ }
+ if (missingIds.some((versionId) => !loadedIds.has(versionId))) {
+ throw new Error('Could not load all project versions')
+ }
+ return versions
+ },
+ }
+ }),
)
const reportsQuery = useQuery(
computed(() => ({
diff --git a/apps/frontend/src/providers/project-review/review-slicer.ts b/apps/frontend/src/providers/project-review/review-slicer.ts
new file mode 100644
index 00000000000..3ba17a195fd
--- /dev/null
+++ b/apps/frontend/src/providers/project-review/review-slicer.ts
@@ -0,0 +1,71 @@
+import type { Labrinth } from '@modrinth/api-client'
+import { createContext } from '@modrinth/ui'
+import { computed, type Ref, ref, shallowRef, watch } from 'vue'
+
+interface SlicerFile {
+ url: string
+ name: string
+}
+
+export interface SlicerTarget {
+ key: string
+ files: SlicerFile[]
+ /** Workspace entry to open once the files are loaded, e.g. `mod.jar/com/example/Mod.class`. */
+ entry?: string
+}
+
+export const [injectReviewSlicer, provideReviewSlicer] =
+ createContext>('ProjectReviewSlicer')
+
+/** Names a file the same way slicer does when loading it from its `url` parameter. */
+export function slicerFileName(url: string) {
+ const name = new URL(url).pathname.split('/').pop()
+ return name?.includes('.') ? name : 'input.jar'
+}
+
+function createTarget(urls: string[], entry?: string): SlicerTarget {
+ return {
+ key: JSON.stringify([urls, entry ?? null]),
+ files: urls.map((url) => ({ url, name: slicerFileName(url) })),
+ entry,
+ }
+}
+
+/**
+ * Tracks the files the slicer tab should display for the current project. Until a reviewer opens
+ * files explicitly, the latest version's primary file is used.
+ */
+export function createReviewSlicer(
+ projectId: Readonly[>,
+ versions: Readonly][>,
+) {
+ const selected = shallowRef()
+ const openRequests = ref(0)
+ const loadingFiles = ref(false)
+
+ watch(projectId, () => {
+ selected.value = undefined
+ loadingFiles.value = false
+ })
+
+ const target = computed(() => {
+ if (selected.value) return selected.value
+ const latest = versions.value[0]
+ const file = latest?.files.find(({ primary }) => primary) ?? latest?.files[0]
+ return file ? createTarget([file.url]) : undefined
+ })
+
+ /**
+ * Loads the given file URLs into the slicer tab and brings it to the front. Reopening the same
+ * files with a different entry only opens that entry, without reloading the workspace.
+ */
+ function open(urls: string[], entry?: string) {
+ if (!urls.length) return
+ const next = createTarget(urls, entry)
+ if (next.key !== target.value?.key) loadingFiles.value = true
+ selected.value = next
+ openRequests.value++
+ }
+
+ return { target, open, openRequests, loadingFiles }
+}
diff --git a/package.json b/package.json
index 6ef08d7388f..636e2cb9dd3 100644
--- a/package.json
+++ b/package.json
@@ -46,7 +46,7 @@
"vue": "^3.5.43",
"yaml": "^2.8.2"
},
- "packageManager": "pnpm@12.8.1",
+ "packageManager": "pnpm@12.11.2",
"engines": {
"node": ">=24.15.0"
},
diff --git a/packages/moderation/src/data/issues/icon.ts b/packages/moderation/src/data/issues/icon.ts
index de644bed6db..47cf4c4256c 100644
--- a/packages/moderation/src/data/issues/icon.ts
+++ b/packages/moderation/src/data/issues/icon.ts
@@ -1,12 +1,11 @@
import { ImageIcon } from '@modrinth/assets'
-import { issue, panel, toggle } from './component-builders/builders'
-import { rulesAiImagesIssue } from './rules'
-import { issueTargets } from './component-builders/targets'
-
import flashingIconMessage from '../messages/checklist/messages/icon/flashing.md'
import inappropriateIconMessage from '../messages/checklist/messages/icon/inappropriate.md'
import iconSimilaritiesMessage from '../messages/checklist/messages/icon/similarities.md'
+import { issue, panel, toggle } from './component-builders/builders'
+import { issueTargets } from './component-builders/targets'
+import { rulesAiImagesIssue } from './rules'
export const flashingIconIssue = issue({
id: 'flashing-icon-issue',
diff --git a/packages/moderation/src/data/keybinds.ts b/packages/moderation/src/data/keybinds.ts
index 2802da6831e..926715e7e8c 100644
--- a/packages/moderation/src/data/keybinds.ts
+++ b/packages/moderation/src/data/keybinds.ts
@@ -70,13 +70,6 @@ const keybinds: { [id: string]: KeybindListener } = {
enabled: (ctx) => ctx.available('toggle-right'),
action: (ctx) => ctx.run('toggle-right'),
},
- 'review-toggle-bottom': {
- keybind: 'Shift+ArrowDown',
- description: 'Toggle tools panel',
- scope: 'review-actions',
- enabled: (ctx) => ctx.available('toggle-bottom'),
- action: (ctx) => ctx.run('toggle-bottom'),
- },
'review-back': {
keybind: 'Alt+ArrowLeft',
description: 'Previous project',
@@ -176,6 +169,12 @@ const keybinds: { [id: string]: KeybindListener } = {
scope: 'project-review',
action: (ctx) => ctx.openTab('tech-review'),
},
+ 'review-tab-slicer': {
+ keybind: 'L',
+ description: 'Open slicer tab',
+ scope: 'project-review',
+ action: (ctx) => ctx.openTab('slicer'),
+ },
'toggle-collapse': {
keybind: 'Shift+C',
description: 'Toggle collapse/expand',
diff --git a/packages/moderation/src/types/keybinds.ts b/packages/moderation/src/types/keybinds.ts
index ddb145d894a..ca79a73e3b4 100644
--- a/packages/moderation/src/types/keybinds.ts
+++ b/packages/moderation/src/types/keybinds.ts
@@ -28,7 +28,14 @@ export type ModerationGlobalContext = {
export type ModerationProjectReviewContext = {
scope: 'project-review'
openTab: (
- tab: 'description' | 'gallery' | 'disclosures' | 'versions' | 'permissions' | 'tech-review',
+ tab:
+ | 'description'
+ | 'gallery'
+ | 'disclosures'
+ | 'versions'
+ | 'permissions'
+ | 'tech-review'
+ | 'slicer',
) => void
}
@@ -53,7 +60,6 @@ export type ReviewShortcutAction =
| 'toggle-left'
| 'toggle-right'
| 'reveal-right'
- | 'toggle-bottom'
| 'back'
| 'next'
| 'exit'
diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue
index 1dd4e8659a3..bbb046cf71e 100644
--- a/packages/ui/src/components/base/Combobox.vue
+++ b/packages/ui/src/components/base/Combobox.vue
@@ -367,10 +367,11 @@ const triggerRef = ref()
const searchTriggerRef = ref>()
const dropdownRef = ref()
const dropdownDocument = shallowRef()
-const teleportTarget = computed(() =>
- dropdownDocument.value?.body ??
- containerRef.value?.ownerDocument.body ??
- (typeof document !== 'undefined' ? document.body : 'body'),
+const teleportTarget = computed(
+ () =>
+ dropdownDocument.value?.body ??
+ containerRef.value?.ownerDocument.body ??
+ (typeof document !== 'undefined' ? document.body : 'body'),
)
const optionsScrollbarRef = ref()
const optionsContainerRef = ref()
@@ -506,7 +507,9 @@ function setOptionRef(el: HTMLElement | null, index: number) {
function setInitialFocus() {
focusedIndex.value = props.listbox
- ? filteredOptions.value.findIndex((opt) => isDropdownOption(opt) && opt.value === props.modelValue)
+ ? filteredOptions.value.findIndex(
+ (opt) => isDropdownOption(opt) && opt.value === props.modelValue,
+ )
: -1
if (focusedIndex.value >= 0) {
diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue
index d709bd5e31b..4a2ce033e2e 100644
--- a/packages/ui/src/components/base/MultiSelect.vue
+++ b/packages/ui/src/components/base/MultiSelect.vue
@@ -588,10 +588,11 @@ const triggerElement = computed(() => {
})
const dropdownRef = ref()
const dropdownDocument = shallowRef()
-const teleportTarget = computed(() =>
- dropdownDocument.value?.body ??
- containerRef.value?.ownerDocument.body ??
- (typeof document !== 'undefined' ? document.body : 'body'),
+const teleportTarget = computed(
+ () =>
+ dropdownDocument.value?.body ??
+ containerRef.value?.ownerDocument.body ??
+ (typeof document !== 'undefined' ? document.body : 'body'),
)
const optionsScrollbarRef = ref()
const optionsContainerRef = ref()
diff --git a/apps/frontend/src/components/analytics-dashboard/AnalyticsLoadingBar.vue b/packages/ui/src/components/base/PageLoadingBar.vue
similarity index 83%
rename from apps/frontend/src/components/analytics-dashboard/AnalyticsLoadingBar.vue
rename to packages/ui/src/components/base/PageLoadingBar.vue
index addfe43f206..cd38ceea848 100644
--- a/apps/frontend/src/components/analytics-dashboard/AnalyticsLoadingBar.vue
+++ b/packages/ui/src/components/base/PageLoadingBar.vue
@@ -1,7 +1,7 @@
- ]