修复子画布缩放并支持Ctrl滚轮与右下角缩放控件

美术资源初始适配不再放大过头

scene层支持Ctrl滚轮锚点缩放,普通滚轮保留翻页
This commit is contained in:
2026-09-04 16:41:39 +08:00
parent 069248b296
commit 820fbfd988
2 changed files with 213 additions and 1 deletions
+54
View File
@@ -5192,6 +5192,60 @@ iframe.preview-frame {
pointer-events: none;
}
.game-resource-book-zoom {
position: absolute;
right: 14px;
bottom: 14px;
z-index: 40;
display: inline-flex;
align-items: center;
gap: 2px;
height: 34px;
padding: 0 4px;
border: 1px solid #ecdcd4;
border-radius: 12px;
background: rgb(255 253 250 / 94%);
box-shadow: 0 6px 18px rgb(112 70 52 / 12%);
backdrop-filter: blur(6px);
}
.game-resource-book-zoom-button,
.game-resource-book-zoom-fit {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: #8c6252;
cursor: pointer;
}
.game-resource-book-zoom-button:hover,
.game-resource-book-zoom-button:focus-visible,
.game-resource-book-zoom-fit:hover,
.game-resource-book-zoom-fit:focus-visible {
background: #f7ebe4;
color: #b5582f;
outline: 0;
}
.game-resource-book-zoom-value {
min-width: 42px;
color: #6d4b3f;
font-size: 11px;
font-variant-numeric: tabular-nums;
text-align: center;
white-space: nowrap;
}
.game-resource-book-zoom-fit {
margin-left: 2px;
}
.game-resource-book-scene.is-measuring {
opacity: 0;
}
@@ -1,6 +1,8 @@
import type { ImageCanvasHostScope } from '@genarrative/image-canvas-core';
import {
type CanvasViewport,
MAX_SCALE,
MIN_SCALE,
resolveViewportFromWheel,
} from '@genarrative/image-canvas-core';
import {
@@ -11,9 +13,12 @@ import {
Image,
Info,
ListFilter,
Maximize2,
Minus,
Music2,
Pause,
Play,
Plus,
RotateCcw,
Search,
Settings2,
@@ -36,6 +41,7 @@ import {
useMemo,
useRef,
useState,
type WheelEvent as ReactWheelEvent,
} from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -326,6 +332,7 @@ function defaultResourceCanvasViewports(): ResourceCanvasViewportBySortMode {
const RESOURCE_CANVAS_FALLBACK_WIDTH = 900;
const RESOURCE_CANVAS_FALLBACK_HEIGHT = 640;
const RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE = 1;
function resourceCanvasViewportElement(element: HTMLElement | null) {
const pageCanvas = element?.querySelector<HTMLElement>(
@@ -977,6 +984,11 @@ function ResourceBookScene({
onPointerMove,
onPointerUp,
onPointerCancel,
onWheel,
zoomPercent,
onZoomOut,
onZoomIn,
onZoomFit,
}: {
resources: ProjectResource[];
visibleCategoryOrder: readonly ResourceCategory[];
@@ -1005,6 +1017,11 @@ function ResourceBookScene({
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;
onWheel: (event: ReactWheelEvent<HTMLDivElement>) => void;
zoomPercent: number;
onZoomOut: () => void;
onZoomIn: () => void;
onZoomFit: () => void;
}) {
const resourcesByCategory = new Map<ResourceCategory, ProjectResource[]>();
resources.forEach((resource) => {
@@ -1024,7 +1041,42 @@ function ResourceBookScene({
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onWheel={onWheel}
>
{resourceBookView === 'child' ? (
<div className="game-resource-book-zoom">
<button
type="button"
className="game-resource-book-zoom-button"
aria-label="缩小画布"
title="缩小画布"
onClick={onZoomOut}
>
<Minus size={16} aria-hidden="true" />
</button>
<span className="game-resource-book-zoom-value" aria-live="polite">
{Math.round(zoomPercent)}%
</span>
<button
type="button"
className="game-resource-book-zoom-button"
aria-label="放大画布"
title="放大画布"
onClick={onZoomIn}
>
<Plus size={16} aria-hidden="true" />
</button>
<button
type="button"
className="game-resource-book-zoom-fit"
aria-label="将画布调整到适应内容大小"
title="适应内容大小"
onClick={onZoomFit}
>
<Maximize2 size={16} aria-hidden="true" />
</button>
</div>
) : null}
{visibleCategoryOrder.map((category) => {
const rect = overviewRects.get(category);
const categoryResources = resourcesByCategory.get(category) ?? [];
@@ -2414,6 +2466,7 @@ export default function ProjectDevelopmentView({
bounds: switchFitBounds,
canvasSize: resourceBookSceneSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE,
});
setResourceBookTransition('returning');
scheduleResourceBookTransition(() => {
@@ -2443,6 +2496,7 @@ export default function ProjectDevelopmentView({
bounds: fitBounds,
canvasSize: resourceBookSceneSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE,
}),
);
setActiveResourceCategory(category);
@@ -3419,10 +3473,109 @@ export default function ProjectDevelopmentView({
bounds: resourceCanvasFitBounds,
canvasSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE,
}),
);
}, [activePageCategory, resourceCanvasFitBounds, setResourceCanvasViewport]);
const fitResourceBookScene = useCallback(() => {
if (!activePageCategory) {
return;
}
setResourceCanvasViewport(
activePageCategory,
fitResourceCanvasViewportToContent({
bounds: resourceCanvasFitBounds,
canvasSize: resourceBookSceneSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE,
}),
);
}, [
activePageCategory,
resourceBookSceneSize,
resourceCanvasFitBounds,
setResourceCanvasViewport,
]);
const zoomResourceBookSceneBy = useCallback(
(factor: number) => {
const category = activePageCategory;
if (!category) {
return;
}
const viewport = resourceCanvasViewportRef.current;
const scale = Math.min(
MAX_SCALE,
Math.max(MIN_SCALE, viewport.scale * factor),
);
if (scale === viewport.scale) {
return;
}
const sceneElement = resourceBookManagerRef.current;
const rect = sceneElement?.getBoundingClientRect();
const anchorX = rect ? rect.width / 2 : 0;
const anchorY = rect ? rect.height / 2 : 0;
const ratio = scale / viewport.scale;
setResourceCanvasViewport(category, {
scale,
x: anchorX - (anchorX - viewport.x) * ratio,
y: anchorY - (anchorY - viewport.y) * ratio,
});
},
[activePageCategory, setResourceCanvasViewport],
);
const handleResourceBookSceneWheel = useCallback(
(event: ReactWheelEvent<HTMLDivElement>) => {
const category = activePageCategory;
const sceneElement = resourceBookManagerRef.current;
if (!category || !sceneElement) {
return;
}
if (!(event.ctrlKey || event.metaKey)) {
const pageCanvas =
resourceCanvasRef.current?.querySelector<HTMLElement>(
'[data-resource-section-scroll]',
);
if (pageCanvas) {
pageCanvas.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
clientX: event.clientX,
clientY: event.clientY,
deltaX: event.deltaX,
deltaY: event.deltaY,
deltaMode: event.deltaMode,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
}),
);
event.preventDefault();
}
return;
}
event.preventDefault();
const rect = sceneElement.getBoundingClientRect();
const result = resolveViewportFromWheel({
viewport: resourceCanvasViewportRef.current,
deltaX: event.deltaX,
deltaY: event.deltaY,
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
screenPoint: {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
},
});
setResourceCanvasViewport(category, result.viewport, true);
},
[activePageCategory, setResourceCanvasViewport],
);
const handleResourceCardPointerDown = useCallback(
(
event: ReactPointerEvent<HTMLDivElement>,
@@ -3564,7 +3717,7 @@ export default function ProjectDevelopmentView({
const target = event.target as HTMLElement;
if (
target.closest(
'button, input, textarea, select, a, .game-resource-card, .game-resource-book-scene-titlebar',
'button, input, textarea, select, a, .game-resource-card, .game-resource-book-scene-titlebar, .game-resource-book-zoom',
)
) {
return;
@@ -5388,6 +5541,11 @@ export default function ProjectDevelopmentView({
onPointerMove={handleResourceCanvasPointerMove}
onPointerUp={stopResourceCanvasPan}
onPointerCancel={stopResourceCanvasPan}
onWheel={handleResourceBookSceneWheel}
zoomPercent={activeResourceCanvasViewport.scale * 100}
onZoomOut={() => zoomResourceBookSceneBy(1 / 1.2)}
onZoomIn={() => zoomResourceBookSceneBy(1.2)}
onZoomFit={fitResourceBookScene}
/>
<label className="game-resource-search">
<Search size={16} aria-hidden="true" />