diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css
index a06b518c7..8017ff63c 100644
--- a/apps/ai-game-creator-shell/src/styles.css
+++ b/apps/ai-game-creator-shell/src/styles.css
@@ -786,7 +786,7 @@ textarea {
}
.client-auth-panel button:not(:disabled):active {
- transform: translateY(0);
+ translate: 0 0;
}
.client-auth-panel button:disabled {
@@ -5604,6 +5604,530 @@ iframe.preview-frame {
min-height: 0;
}
+.game-resource-book-manager {
+ position: relative;
+ overflow: hidden;
+ background: #fffdfa;
+}
+
+.game-resource-book-scene {
+ position: absolute;
+ z-index: 20;
+ inset: 0;
+ overflow: hidden;
+ pointer-events: none;
+ transition: none;
+}
+
+.game-resource-book-scene-world {
+ position: absolute;
+ inset: 0;
+ transform-origin: 0 0;
+ transition: none;
+}
+
+.game-resource-book-transition-layer {
+ position: absolute;
+ inset: 0;
+ z-index: 35;
+ overflow: hidden;
+ pointer-events: none;
+}
+
+.game-resource-book-manager[data-book-motion='running']
+ .game-resource-book-scene-world {
+ opacity: 0;
+}
+
+.game-resource-book-scene--child {
+ pointer-events: auto;
+}
+
+.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--main.is-measuring {
+ visibility: hidden;
+}
+
+.game-resource-book-scene::before {
+ position: absolute;
+ z-index: 0;
+ inset: 0;
+ background:
+ radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px,
+ #fffdfa;
+ content: '';
+ opacity: 0;
+ pointer-events: none;
+ transition: none;
+}
+
+.game-resource-book-scene--child::before {
+ opacity: 1;
+}
+
+.game-resource-book-scene-titlebar {
+ position: absolute;
+ z-index: 30;
+ min-height: 42px;
+ border: 1px solid #ebd9cf;
+ border-radius: 16px 16px 0 0;
+ background: rgb(255 250 246 / 96%);
+ box-shadow: 0 8px 18px rgb(112 70 52 / 8%);
+ pointer-events: none;
+ transform: translateY(0);
+ transition: none;
+}
+
+.game-resource-book-scene-titlebar.is-active {
+ border-radius: 0;
+ pointer-events: auto;
+}
+
+.game-resource-book-scene-titlebar .game-resource-book-titlebar {
+ min-height: 42px;
+ border-bottom: 0;
+ background: transparent;
+}
+
+.game-resource-book-scene-card {
+ position: absolute;
+ z-index: 25;
+ pointer-events: none;
+ translate: 0 0;
+ transition: none;
+}
+
+.game-resource-book-manager--main:has(
+ .game-resource-book-thumbnail[data-resource-book-category='code']:hover,
+ .game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible
+ )
+ .game-resource-book-scene-titlebar[data-resource-book-category='code'],
+.game-resource-book-manager--main:has(
+ .game-resource-book-thumbnail[data-resource-book-category='document']:hover,
+ .game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible
+ )
+ .game-resource-book-scene-titlebar[data-resource-book-category='document'],
+.game-resource-book-manager--main:has(
+ .game-resource-book-thumbnail[data-resource-book-category='version']:hover,
+ .game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible
+ )
+ .game-resource-book-scene-titlebar[data-resource-book-category='version'],
+.game-resource-book-manager--main:has(
+ .game-resource-book-thumbnail[data-resource-book-category='art']:hover,
+ .game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible
+ )
+ .game-resource-book-scene-titlebar[data-resource-book-category='art'],
+.game-resource-book-manager--main:has(
+ .game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
+ .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
+ )
+ .game-resource-book-scene-titlebar[data-resource-book-category='audio'] {
+ border-top-color: #cc8060;
+ border-right-color: #cc8060;
+ border-bottom-color: #ebd9cf;
+ border-left-color: #cc8060;
+}
+
+.game-resource-book-scene-card .game-resource-card {
+ transition: box-shadow 180ms ease;
+}
+
+.game-resource-book-scene--main
+ .game-resource-book-scene-card
+ .game-resource-card {
+ border-radius: 10px;
+ box-shadow: 0 5px 12px rgb(112 70 52 / 10%);
+}
+
+.game-resource-book-scene--child
+ .game-resource-book-scene-card.is-expanded
+ .game-resource-card {
+ border-radius: 12px;
+}
+
+.game-resource-book-main.is-background,
+.game-resource-canvas.is-background {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.game-resource-book-thumbnail-titlebar--placeholder {
+ min-height: 42px;
+ border-bottom: 1px solid #f0e2da;
+ background: rgb(255 250 246 / 90%);
+}
+
+.game-resource-book-main {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ touch-action: none;
+ user-select: none;
+ cursor: grab;
+ background:
+ radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px,
+ linear-gradient(145deg, #fffdfa 0%, #fff8f2 100%);
+}
+
+.game-resource-book-main:active {
+ cursor: grabbing;
+}
+
+.game-resource-book-main-world {
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+ padding: clamp(24px, 4vw, 52px);
+ transform-origin: 0 0;
+}
+
+.game-resource-book-main-heading {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 24px;
+ max-width: 1120px;
+ margin: 0 auto 24px;
+}
+
+.game-resource-book-main-heading small {
+ display: block;
+ margin-bottom: 4px;
+ color: #b18370;
+ font-size: 11px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.game-resource-book-main-heading h2 {
+ margin: 0;
+ color: #56372d;
+ font-size: clamp(22px, 3vw, 32px);
+ line-height: 1.1;
+}
+
+.game-resource-book-main-heading > span {
+ color: #a4877a;
+ font-size: 12px;
+}
+
+.game-resource-book-main-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
+ gap: 18px;
+ max-width: 1120px;
+ margin: 0 auto;
+}
+
+.game-resource-book-thumbnail {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-height: 210px;
+ padding: 0;
+ overflow: hidden;
+ border: 1px solid #ebd9cf;
+ border-radius: 16px;
+ background: rgb(255 255 255 / 82%);
+ color: #67483d;
+ cursor: pointer;
+ text-align: left;
+ box-shadow: 0 12px 28px rgb(112 70 52 / 9%);
+ transition:
+ border-color 180ms ease,
+ box-shadow 180ms ease,
+ transform 180ms ease;
+}
+
+.game-resource-book-thumbnail:hover,
+.game-resource-book-thumbnail:focus-visible {
+ border-color: #cc8060;
+ outline: 0;
+ box-shadow: 0 16px 34px rgb(158 87 57 / 18%);
+ transform: none;
+}
+
+.game-resource-book-thumbnail-titlebar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-height: 42px;
+ padding: 0 14px;
+ border-bottom: 1px solid #f0e2da;
+ background: rgb(255 250 246 / 90%);
+}
+
+.game-resource-book-titlebar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ width: 100%;
+ min-height: 42px;
+ padding: 0 14px;
+ border-bottom: 1px solid #f0e2da;
+ background: rgb(255 250 246 / 90%);
+}
+
+.game-resource-book-titlebar-label,
+.game-resource-book-titlebar-meta {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+}
+
+.game-resource-book-titlebar-label strong {
+ overflow: hidden;
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.game-resource-book-titlebar-meta {
+ flex: 0 0 auto;
+ gap: 10px;
+}
+
+.game-resource-book-titlebar-meta small {
+ color: #b18f81;
+ font-size: 11px;
+}
+
+.game-resource-book-thumbnail-titlebar {
+ padding: 0;
+ border-bottom: 0;
+}
+
+.game-resource-book-thumbnail-titlebar > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+}
+
+.game-resource-book-thumbnail-titlebar strong {
+ overflow: hidden;
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.game-resource-book-thumbnail-titlebar small {
+ flex: 0 0 auto;
+ color: #b18f81;
+ font-size: 11px;
+}
+
+.game-resource-book-thumbnail-body {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(94px, 1fr));
+ align-content: center;
+ gap: 12px;
+ flex: 1 1 auto;
+ min-height: 132px;
+ padding: 18px;
+}
+
+.game-resource-book-stack {
+ position: relative;
+ display: block;
+ min-height: 78px;
+}
+
+.game-resource-book-stack:has(.game-resource-card) {
+ min-height: 132px;
+}
+
+.game-resource-book-stack-card {
+ position: absolute;
+ inset: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ min-height: 64px;
+ padding: 8px 9px;
+ overflow: hidden;
+ border: 1px solid #e7cfc2;
+ border-radius: 10px;
+ background: linear-gradient(145deg, #fff 0%, #fff6ef 100%);
+ box-shadow: 0 5px 12px rgb(112 70 52 / 10%);
+ transform: translateY(calc(var(--stack-index, 0) * 5px));
+}
+
+.game-resource-book-stack-card:nth-last-child(2) {
+ transform: translateY(5px) rotate(-1deg);
+}
+
+.game-resource-book-stack-card:nth-last-child(3) {
+ transform: translateY(10px) rotate(1.4deg);
+}
+
+.game-resource-book-stack-card small {
+ overflow: hidden;
+ color: #b48876;
+ font-size: 9px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.game-resource-book-stack-card strong {
+ display: block;
+ margin-top: 4px;
+ overflow: hidden;
+ color: #68463a;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.game-resource-book-stack-card.is-blurred {
+ z-index: 0;
+ align-items: center;
+ color: #aa8576;
+ filter: blur(1.7px);
+ font-size: 12px;
+ opacity: 0.72;
+ transform: translateY(15px) scale(0.96);
+}
+
+.game-resource-book-stack-card--control {
+ width: 100%;
+ min-height: 128px;
+ padding: 0;
+ overflow: visible;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+}
+
+.game-resource-book-stack-card--control .game-resource-card {
+ width: 100%;
+ min-width: 0;
+ height: 100%;
+ min-height: 0;
+}
+
+.game-resource-book-thumbnail-empty {
+ display: grid;
+ min-height: 96px;
+ border: 1px dashed #e1cfc5;
+ border-radius: 12px;
+ color: #b79b8e;
+ font-size: 12px;
+ place-items: center;
+}
+
+.game-resource-book-thumbnail-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ border-top: 1px solid #f0e2da;
+ color: #a98271;
+ font-size: 10px;
+}
+
+.game-resource-page-heading-meta {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.game-resource-page-collapse {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ min-height: 28px;
+ padding: 0 9px;
+ border: 1px solid #e5cfc4;
+ border-radius: 8px;
+ background: rgb(255 250 246 / 85%);
+ color: #9d6650;
+ cursor: pointer;
+ font-size: 11px;
+}
+
+.game-resource-page-collapse:hover,
+.game-resource-page-collapse:focus-visible {
+ border-color: #cc8060;
+ outline: 0;
+ box-shadow: 0 3px 10px rgb(158 87 57 / 14%);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .game-resource-book-main {
+ animation: none;
+ }
+
+ .game-resource-book-scene,
+ .game-resource-book-scene::before,
+ .game-resource-book-scene-titlebar,
+ .game-resource-book-scene-card,
+ .game-resource-book-scene-card .game-resource-card,
+ .game-resource-book-thumbnail {
+ transition: none !important;
+ }
+}
+
.game-resource-search {
display: none;
align-items: center;
@@ -9337,528 +9861,3 @@ iframe.preview-frame {
color: var(--platform-button-primary-text);
place-items: center;
}
-
-/* 资源画本视觉层(自 #283 V2.0 迁移,供 V3 资源画布复用) */
-.game-resource-book-manager {
- position: relative;
- overflow: hidden;
- background: #fffdfa;
-}
-
-.game-resource-book-scene {
- position: absolute;
- z-index: 20;
- inset: 0;
- overflow: hidden;
- pointer-events: none;
- transition: none;
-}
-
-.game-resource-book-scene-world {
- position: absolute;
- inset: 0;
- transform-origin: 0 0;
- transition: none;
-}
-
-.game-resource-book-transition-layer {
- position: absolute;
- inset: 0;
- z-index: 35;
- overflow: hidden;
- pointer-events: none;
-}
-
-.game-resource-book-manager[data-book-motion='running']
- .game-resource-book-scene-world {
- opacity: 0;
-}
-
-.game-resource-book-scene--child {
- pointer-events: auto;
-}
-
-.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--main.is-measuring {
- visibility: hidden;
-}
-
-.game-resource-book-scene::before {
- position: absolute;
- z-index: 0;
- inset: 0;
- background:
- radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px,
- #fffdfa;
- content: '';
- opacity: 0;
- pointer-events: none;
- transition: none;
-}
-
-.game-resource-book-scene--child::before {
- opacity: 1;
-}
-
-.game-resource-book-scene-titlebar {
- position: absolute;
- z-index: 30;
- min-height: 42px;
- border: 1px solid #ebd9cf;
- border-radius: 16px 16px 0 0;
- background: rgb(255 250 246 / 96%);
- box-shadow: 0 8px 18px rgb(112 70 52 / 8%);
- pointer-events: none;
- transform: translateY(0);
- transition: none;
-}
-
-.game-resource-book-scene-titlebar.is-active {
- border-radius: 0;
- pointer-events: auto;
-}
-
-.game-resource-book-scene-titlebar .game-resource-book-titlebar {
- min-height: 42px;
- border-bottom: 0;
- background: transparent;
-}
-
-.game-resource-book-scene-card {
- position: absolute;
- z-index: 25;
- pointer-events: none;
- translate: 0 0;
- transition: none;
-}
-
-.game-resource-book-manager--main:has(
- .game-resource-book-thumbnail[data-resource-book-category='code']:hover,
- .game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible
- )
- .game-resource-book-scene-titlebar[data-resource-book-category='code'],
-.game-resource-book-manager--main:has(
- .game-resource-book-thumbnail[data-resource-book-category='document']:hover,
- .game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible
- )
- .game-resource-book-scene-titlebar[data-resource-book-category='document'],
-.game-resource-book-manager--main:has(
- .game-resource-book-thumbnail[data-resource-book-category='version']:hover,
- .game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible
- )
- .game-resource-book-scene-titlebar[data-resource-book-category='version'],
-.game-resource-book-manager--main:has(
- .game-resource-book-thumbnail[data-resource-book-category='art']:hover,
- .game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible
- )
- .game-resource-book-scene-titlebar[data-resource-book-category='art'],
-.game-resource-book-manager--main:has(
- .game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
- .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
- )
- .game-resource-book-scene-titlebar[data-resource-book-category='audio'] {
- border-top-color: #cc8060;
- border-right-color: #cc8060;
- border-bottom-color: #ebd9cf;
- border-left-color: #cc8060;
-}
-
-.game-resource-book-scene-card .game-resource-card {
- transition: box-shadow 180ms ease;
-}
-
-.game-resource-book-scene--main
- .game-resource-book-scene-card
- .game-resource-card {
- border-radius: 10px;
- box-shadow: 0 5px 12px rgb(112 70 52 / 10%);
-}
-
-.game-resource-book-scene--child
- .game-resource-book-scene-card.is-expanded
- .game-resource-card {
- border-radius: 12px;
-}
-
-.game-resource-book-main.is-background,
-.game-resource-canvas.is-background {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- opacity: 0;
- pointer-events: none;
-}
-
-.game-resource-book-thumbnail-titlebar--placeholder {
- min-height: 42px;
- border-bottom: 1px solid #f0e2da;
- background: rgb(255 250 246 / 90%);
-}
-
-.game-resource-book-main {
- position: absolute;
- inset: 0;
- overflow: hidden;
- touch-action: none;
- user-select: none;
- cursor: grab;
- background:
- radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px,
- linear-gradient(145deg, #fffdfa 0%, #fff8f2 100%);
-}
-
-.game-resource-book-main:active {
- cursor: grabbing;
-}
-
-.game-resource-book-main-world {
- display: flex;
- flex-direction: column;
- min-height: 100%;
- padding: clamp(24px, 4vw, 52px);
- transform-origin: 0 0;
-}
-
-.game-resource-book-main-heading {
- display: flex;
- align-items: flex-end;
- justify-content: space-between;
- gap: 24px;
- max-width: 1120px;
- margin: 0 auto 24px;
-}
-
-.game-resource-book-main-heading small {
- display: block;
- margin-bottom: 4px;
- color: #b18370;
- font-size: 11px;
- letter-spacing: 0.12em;
- text-transform: uppercase;
-}
-
-.game-resource-book-main-heading h2 {
- margin: 0;
- color: #56372d;
- font-size: clamp(22px, 3vw, 32px);
- line-height: 1.1;
-}
-
-.game-resource-book-main-heading > span {
- color: #a4877a;
- font-size: 12px;
-}
-
-.game-resource-book-main-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
- gap: 18px;
- max-width: 1120px;
- margin: 0 auto;
-}
-
-.game-resource-book-thumbnail {
- position: relative;
- display: flex;
- flex-direction: column;
- min-height: 210px;
- padding: 0;
- overflow: hidden;
- border: 1px solid #ebd9cf;
- border-radius: 16px;
- background: rgb(255 255 255 / 82%);
- color: #67483d;
- cursor: pointer;
- text-align: left;
- box-shadow: 0 12px 28px rgb(112 70 52 / 9%);
- transition:
- border-color 180ms ease,
- box-shadow 180ms ease,
- transform 180ms ease;
-}
-
-.game-resource-book-thumbnail:hover,
-.game-resource-book-thumbnail:focus-visible {
- border-color: #cc8060;
- outline: 0;
- box-shadow: 0 16px 34px rgb(158 87 57 / 18%);
- transform: none;
-}
-
-.game-resource-book-thumbnail-titlebar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- min-height: 42px;
- padding: 0 14px;
- border-bottom: 1px solid #f0e2da;
- background: rgb(255 250 246 / 90%);
-}
-
-.game-resource-book-titlebar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- width: 100%;
- min-height: 42px;
- padding: 0 14px;
- border-bottom: 1px solid #f0e2da;
- background: rgb(255 250 246 / 90%);
-}
-
-.game-resource-book-titlebar-label,
-.game-resource-book-titlebar-meta {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- min-width: 0;
-}
-
-.game-resource-book-titlebar-label strong {
- overflow: hidden;
- font-size: 13px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.game-resource-book-titlebar-meta {
- flex: 0 0 auto;
- gap: 10px;
-}
-
-.game-resource-book-titlebar-meta small {
- color: #b18f81;
- font-size: 11px;
-}
-
-.game-resource-book-thumbnail-titlebar {
- padding: 0;
- border-bottom: 0;
-}
-
-.game-resource-book-thumbnail-titlebar > span {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- min-width: 0;
-}
-
-.game-resource-book-thumbnail-titlebar strong {
- overflow: hidden;
- font-size: 13px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.game-resource-book-thumbnail-titlebar small {
- flex: 0 0 auto;
- color: #b18f81;
- font-size: 11px;
-}
-
-.game-resource-book-thumbnail-body {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(94px, 1fr));
- align-content: center;
- gap: 12px;
- flex: 1 1 auto;
- min-height: 132px;
- padding: 18px;
-}
-
-.game-resource-book-stack {
- position: relative;
- display: block;
- min-height: 78px;
-}
-
-.game-resource-book-stack:has(.game-resource-card) {
- min-height: 132px;
-}
-
-.game-resource-book-stack-card {
- position: absolute;
- inset: 0 0 auto;
- display: flex;
- flex-direction: column;
- justify-content: center;
- min-height: 64px;
- padding: 8px 9px;
- overflow: hidden;
- border: 1px solid #e7cfc2;
- border-radius: 10px;
- background: linear-gradient(145deg, #fff 0%, #fff6ef 100%);
- box-shadow: 0 5px 12px rgb(112 70 52 / 10%);
- transform: translateY(calc(var(--stack-index, 0) * 5px));
-}
-
-.game-resource-book-stack-card:nth-last-child(2) {
- transform: translateY(5px) rotate(-1deg);
-}
-
-.game-resource-book-stack-card:nth-last-child(3) {
- transform: translateY(10px) rotate(1.4deg);
-}
-
-.game-resource-book-stack-card small {
- overflow: hidden;
- color: #b48876;
- font-size: 9px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.game-resource-book-stack-card strong {
- display: block;
- margin-top: 4px;
- overflow: hidden;
- color: #68463a;
- font-size: 10px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.game-resource-book-stack-card.is-blurred {
- z-index: 0;
- align-items: center;
- color: #aa8576;
- filter: blur(1.7px);
- font-size: 12px;
- opacity: 0.72;
- transform: translateY(15px) scale(0.96);
-}
-
-.game-resource-book-stack-card--control {
- width: 100%;
- min-height: 128px;
- padding: 0;
- overflow: visible;
- border: 0;
- background: transparent;
- box-shadow: none;
-}
-
-.game-resource-book-stack-card--control .game-resource-card {
- width: 100%;
- min-width: 0;
- height: 100%;
- min-height: 0;
-}
-
-.game-resource-book-thumbnail-empty {
- display: grid;
- min-height: 96px;
- border: 1px dashed #e1cfc5;
- border-radius: 12px;
- color: #b79b8e;
- font-size: 12px;
- place-items: center;
-}
-
-.game-resource-book-thumbnail-footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 14px;
- border-top: 1px solid #f0e2da;
- color: #a98271;
- font-size: 10px;
-}
-
-.game-resource-page-heading-meta {
- display: inline-flex;
- align-items: center;
- gap: 10px;
-}
-
-.game-resource-page-collapse {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- min-height: 28px;
- padding: 0 9px;
- border: 1px solid #e5cfc4;
- border-radius: 8px;
- background: rgb(255 250 246 / 85%);
- color: #9d6650;
- cursor: pointer;
- font-size: 11px;
-}
-
-.game-resource-page-collapse:hover,
-.game-resource-page-collapse:focus-visible {
- border-color: #cc8060;
- outline: 0;
- box-shadow: 0 3px 10px rgb(158 87 57 / 14%);
-}
-
-@media (prefers-reduced-motion: reduce) {
- .game-resource-book-main {
- animation: none;
- }
-
- .game-resource-book-scene,
- .game-resource-book-scene::before,
- .game-resource-book-scene-titlebar,
- .game-resource-book-scene-card,
- .game-resource-book-scene-card .game-resource-card,
- .game-resource-book-thumbnail {
- transition: none !important;
- }
-}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx
new file mode 100644
index 000000000..f5ebb6efc
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx
@@ -0,0 +1,9 @@
+export function ResourceBookTransitionLayer() {
+ return (
+
+ );
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
index aee175cb8..cd9e26062 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
@@ -1,8 +1,5 @@
import type { ImageCanvasHostScope } from '@genarrative/image-canvas-core';
-import {
- type CanvasViewport,
- resolveViewportFromWheel,
-} from '@genarrative/image-canvas-core';
+import type { CanvasViewport } from '@genarrative/image-canvas-core';
import {
AtSign,
Code2,
@@ -13,18 +10,23 @@ import {
Image,
Info,
ListFilter,
+ Maximize2,
+ Minus,
Music2,
Pause,
Play,
+ Plus,
RotateCcw,
Search,
Settings2,
SlidersHorizontal,
Sparkles,
X,
+ ZoomOut,
} from 'lucide-react';
import {
type CSSProperties,
+ Fragment,
memo,
type PointerEvent as ReactPointerEvent,
type ReactNode,
@@ -34,8 +36,10 @@ import {
useId,
useLayoutEffect,
useMemo,
+ useReducer,
useRef,
useState,
+ type WheelEvent as ReactWheelEvent,
} from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -80,12 +84,43 @@ import {
resolveResourceFocusIntent,
type ResourceFocusIntent,
} from './projectResourceLiveUpdateModel';
+import {
+ createResourceBookTransitionController,
+ type ResourceBookTransitionController,
+} from './resourceBookController';
+import {
+ groupResourceBookResourcesByCategory,
+ groupResourceBookStacks,
+ RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
+ resourceBookChildCardLayout,
+ resourceBookOverviewCardLayout,
+ type ResourceBookOverviewRect,
+} from './resourceBookLayout';
+import {
+ initialResourceBookState,
+ resourceBookReducer,
+ resourceBookSceneCategory,
+ resourceBookShowsChildCards,
+ resourceBookShowsOverviewCards,
+ type ResourceBookState,
+} from './resourceBookModel';
+import { ResourceBookTransitionLayer } from './ResourceBookTransitionLayer';
+import {
+ DEFAULT_RESOURCE_BOOK_VIEWPORT,
+ keepResourceBookViewportAtReadableScale,
+ normalizeResourceBookViewport,
+ panResourceBookViewport,
+ resolveResourceBookWheelViewport,
+ zoomResourceBookViewport,
+} from './resourceBookViewport';
+import { createResourceCanvasPageCategorySignature } from './resourceCanvasController';
import {
createResourceCanvasCardSizeByResourceId,
fitResourceCanvasViewportToContent,
normalizeInfiniteResourceCanvasViewport,
RESOURCE_CANVAS_DRAG_THRESHOLD,
RESOURCE_CANVAS_FIT_PADDING,
+ RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
RESOURCE_CANVAS_SECTION_MIN_WIDTH,
RESOURCE_CANVAS_SECTION_ORDER,
@@ -326,15 +361,11 @@ function defaultResourceCanvasViewports(): ResourceCanvasViewportBySortMode {
const RESOURCE_CANVAS_FALLBACK_WIDTH = 900;
const RESOURCE_CANVAS_FALLBACK_HEIGHT = 640;
-const RESOURCE_CANVAS_MIN_DRAG_COORDINATE = -1_000_000;
-const RESOURCE_CANVAS_MAX_DRAG_COORDINATE = 1_000_000;
-
-function normalizeResourceCanvasDragCoordinate(value: number) {
- return Math.min(
- RESOURCE_CANVAS_MAX_DRAG_COORDINATE,
- Math.max(RESOURCE_CANVAS_MIN_DRAG_COORDINATE, Math.round(value)),
- );
-}
+// Keep the child canvas fit ceiling aligned with the established resource
+// canvas behavior. The book split changes ownership of the DOM, not the
+// viewport scale contract.
+const RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE =
+ RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE;
function resourceCanvasViewportElement(element: HTMLElement | null) {
const pageCanvas = element?.querySelector(
@@ -381,7 +412,7 @@ export type ProjectDevelopmentViewProps = {
/**
* 本项目是从「做方案」入口建的。
*
- * 只用来决定工作台在拿到第一个已登记资源之前是否收掉左边的资源画布,不参与任何
+ * 只用来决定工作台在拿到第一个已登记资源之前是否收掉左侧的资源区域,不参与任何
* runtime 分流——做游戏与做素材的链路不受影响。
*/
planningStartMode?: boolean;
@@ -530,6 +561,7 @@ const ResourceCard = memo(function ResourceCard({
relationState,
x,
y,
+ rotation = 0,
dragging,
previewIdentity,
preview,
@@ -553,6 +585,7 @@ const ResourceCard = memo(function ResourceCard({
relationState: 'version-binding' | null;
x: number;
y: number;
+ rotation?: number;
dragging: boolean;
previewIdentity: string;
preview: ProjectResourceCardPreviewState;
@@ -780,6 +813,7 @@ const ResourceCard = memo(function ResourceCard({
className={`game-resource-card${selected ? ' is-selected' : ''}${
relationState ? ` is-relation-${relationState}` : ''
}${dragging ? ' is-dragging' : ''}`}
+ data-resource-card-id={resource.id}
data-preview-kind={kind}
style={
{
@@ -787,6 +821,7 @@ const ResourceCard = memo(function ResourceCard({
'--resource-y': `${y}px`,
'--resource-card-width': `${cardSize.width}px`,
'--resource-card-height': `${cardSize.height}px`,
+ transform: `translate3d(var(--resource-x, 0), var(--resource-y, 0), 0) rotate(${rotation}deg)`,
} as CSSProperties
}
onPointerDown={(event) => onPointerDown(event, resource, x, y)}
@@ -867,6 +902,306 @@ const ResourceCard = memo(function ResourceCard({
);
});
+function ResourceBookThumbnail({
+ category,
+ resources,
+ onOpen,
+ registerElement,
+}: {
+ category: ResourceCategory;
+ resources: ProjectResource[];
+ onOpen: (category: ResourceCategory) => void;
+ registerElement?: (
+ category: ResourceCategory,
+ element: HTMLElement | null,
+ ) => void;
+}) {
+ const stacks = Array.from(
+ resources.reduce((groups, resource) => {
+ const type = projectResourceTypeLabel(resource);
+ const current = groups.get(type) ?? [];
+ current.push(resource);
+ groups.set(type, current);
+ return groups;
+ }, new Map()),
+ );
+
+ return (
+ registerElement?.(category, element)}
+ role="button"
+ tabIndex={0}
+ className="game-resource-book-thumbnail"
+ data-resource-book-category={category}
+ onClick={(event) => {
+ if (
+ (event.target as HTMLElement).closest(
+ 'button, input, a, .game-resource-card',
+ )
+ )
+ return;
+ onOpen(category);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onOpen(category);
+ }
+ }}
+ aria-label={`打开${categoryLabels[category]}`}
+ >
+
+
+ {stacks.length > 0 ? (
+ stacks.map(([type]) => {
+ return (
+
+ );
+ })
+ ) : (
+ 暂无资源
+ )}
+
+
+ 打开{categoryLabels[category]}
+ ↗
+
+
+ );
+}
+
+function ResourceBookTitleBar({
+ category,
+ countLabel,
+ onCollapse,
+}: {
+ category: ResourceCategory;
+ countLabel: string;
+ onCollapse?: () => void;
+}) {
+ const Icon = categoryIcons[category];
+ return (
+
+
+
+ {categoryLabels[category]}
+
+
+ {countLabel}
+ {onCollapse ? (
+
+ ) : null}
+
+
+ );
+}
+
+function ResourceBookScene({
+ resources,
+ visibleCategoryOrder,
+ resourceBookState,
+ overviewRects,
+ positions,
+ cardSizes,
+ visibleResourceIds,
+ cardsReady,
+ viewport,
+ mainViewport,
+ renderCard,
+ onCollapse,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onPointerCancel,
+ onWheel,
+}: {
+ resources: ProjectResource[];
+ visibleCategoryOrder: readonly ResourceCategory[];
+ resourceBookState: ResourceBookState;
+ overviewRects: Map;
+ positions: Map;
+ cardSizes: Map;
+ visibleResourceIds: Set;
+ cardsReady: boolean;
+ viewport: CanvasViewport;
+ mainViewport: CanvasViewport;
+ renderCard: (
+ resource: ProjectResource,
+ layout: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ rotation: number;
+ dragX: number;
+ dragY: number;
+ },
+ ) => ReactNode;
+ onCollapse: () => void;
+ onPointerDown: (event: ReactPointerEvent) => void;
+ onPointerMove: (event: ReactPointerEvent) => void;
+ onPointerUp: (event: ReactPointerEvent) => void;
+ onPointerCancel: (event: ReactPointerEvent) => void;
+ onWheel: (event: ReactWheelEvent) => void;
+}) {
+ const resourcesByCategory = groupResourceBookResourcesByCategory(resources);
+ const sceneCategory = resourceBookSceneCategory(resourceBookState);
+ const showOverviewCards = resourceBookShowsOverviewCards(resourceBookState);
+ const showChildCards = resourceBookShowsChildCards(resourceBookState);
+ const overviewReady = visibleCategoryOrder.every((category) => {
+ const rect = overviewRects.get(category);
+ return Boolean(rect && rect.width > 0 && rect.height > 0);
+ });
+ const safeViewport = normalizeResourceBookViewport(viewport);
+ const safeMainViewport = normalizeResourceBookViewport(mainViewport);
+ const sceneViewport =
+ resourceBookState.view === 'main' ? safeMainViewport : safeViewport;
+
+ return (
+
+
+ {visibleCategoryOrder.map((category) => {
+ const rect = overviewRects.get(category);
+ const categoryResources = resourcesByCategory.get(category) ?? [];
+ const stacks = groupResourceBookStacks(categoryResources);
+ const active = showChildCards && sceneCategory === category;
+ const renderTransitionCards = showOverviewCards;
+ const renderCards = cardsReady && (renderTransitionCards || active);
+ const titleLayout = active
+ ? { left: 0, top: 0, width: '100%', opacity: 1 }
+ : {
+ left: rect?.left ?? 0,
+ top: rect?.top ?? 0,
+ width: `${rect?.width ?? 280}px`,
+ opacity: renderTransitionCards ? 1 : 0,
+ };
+ const titlebarTransform = active
+ ? `translate(${-safeViewport.x / safeViewport.scale}px, ${-safeViewport.y / safeViewport.scale}px) scale(${1 / safeViewport.scale})`
+ : undefined;
+ return (
+
+
+
+
+ {renderCards &&
+ stacks.flatMap(([_type, items], stackColumn) => {
+ const expanded = active;
+ // 总览只铺前几张,避免一个类型素材变多时把整摞堆到同一处;
+ // 数量由栏目标题栏的“N 项”表达,子画布仍然渲染全部资源。
+ const stackItems = expanded
+ ? items
+ : items
+ .filter((resource) =>
+ visibleResourceIds.has(resource.id),
+ )
+ .slice(0, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT);
+ return stackItems.map((resource, stackIndex) => {
+ const position = positions.get(resource.id);
+ const size = cardSizes.get(resource.id) ?? {
+ width: 180,
+ height: 128,
+ };
+ const cardVisible =
+ visibleResourceIds.has(resource.id) &&
+ (renderTransitionCards || expanded);
+ const layout = expanded
+ ? resourceBookChildCardLayout({
+ position,
+ size,
+ })
+ : resourceBookOverviewCardLayout({
+ rect,
+ stackIndex,
+ stackColumn,
+ });
+ return (
+
+ {renderCard(resource, layout)}
+
+ );
+ });
+ })}
+
+ );
+ })}
+
+
+ );
+}
+
export default function ProjectDevelopmentView({
orchestrationMode = 'professional-dag',
projectName,
@@ -885,6 +1220,39 @@ export default function ProjectDevelopmentView({
const professionalDagVisible = orchestrationMode === 'professional-dag';
const [mode, setMode] = useState('resources');
const [runtimeInspectMode, setRuntimeInspectMode] = useState(false);
+ const [resourceBookState, dispatchResourceBook] = useReducer(
+ resourceBookReducer,
+ initialResourceBookState,
+ );
+ const resourceBookView = resourceBookState.view;
+ const [resourceBookMainViewport, setResourceBookMainViewport] =
+ useState({ x: 0, y: 0, scale: 1 });
+ const resourceBookMainViewportRef = useRef({
+ x: 0,
+ y: 0,
+ scale: 1,
+ });
+ const resourceBookMainPanRef = useRef<{
+ pointerId: number;
+ startClientX: number;
+ startClientY: number;
+ startViewport: CanvasViewport;
+ } | null>(null);
+ const resourceBookTransitionControllerRef =
+ useRef(
+ createResourceBookTransitionController(),
+ );
+ const resourceBookManagerRef = useRef(null);
+ const resourceBookThumbnailElementsRef = useRef(
+ new Map(),
+ );
+ const [resourceBookOverviewRects, setResourceBookOverviewRects] = useState(
+ () => new Map(),
+ );
+ const [resourceBookSceneSize, setResourceBookSceneSize] = useState({
+ width: 900,
+ height: 640,
+ });
const [sortMode, setSortMode] = useState('dependency');
const [searchText, setSearchText] = useState('');
const [resourceCanvasViewports, setResourceCanvasViewports] =
@@ -971,20 +1339,6 @@ export default function ProjectDevelopmentView({
scale: 1,
});
const resourceCanvasFitKeysRef = useRef>(new Set());
- const resourceCanvasPageWheelRef = useRef<{
- activeCategory: ResourceCategory | null;
- accumulatedDeltaY: number;
- lastSwitchAt: number;
- pendingStepCount: number;
- pendingTimerId: number | null;
- }>({
- activeCategory: null,
- accumulatedDeltaY: 0,
- lastSwitchAt: 0,
- pendingStepCount: 0,
- pendingTimerId: null,
- });
- const renderedResourceCategoryRef = useRef(null);
const resourceCanvasPanRef = useRef<{
pointerId: number;
category: ResourceCategory;
@@ -1319,7 +1673,7 @@ export default function ProjectDevelopmentView({
() => resources.filter((resource) => resource.category !== 'code'),
[resources],
);
- // 做方案在拿到第一个产物之前,左边的资源画布是空的(文档 0 项、项目版本 0 项),而澄清
+ // 做方案在拿到第一个产物之前,左侧的资源区域是空的(文档 0 项、项目版本 0 项),而澄清
// 问答、决定卡和 GDD 审批全挤在右边那一列里。这段时间先把画布收掉让对话铺满,一旦有第
// 一个已登记资源就自动恢复双栏。用 CSS 收而不是不渲染:画布里的页签、缩放和选中状态都
// 留着,恢复双栏时不会被重置。
@@ -1366,6 +1720,8 @@ export default function ProjectDevelopmentView({
mode: sortMode,
resources: canvasResources,
canvasRef: resourceCanvasRef,
+ // 资源卡片渲染在资源画本场景里,可见性判定要用真正包含它们的容器。
+ intersectionRootRef: resourceBookManagerRef,
eagerPreviewLimit: 12,
previewVersionByResourceId: resourcePreviewVersionByResourceId,
});
@@ -1512,9 +1868,23 @@ export default function ProjectDevelopmentView({
() => JSON.stringify([projectPath, manifest.projectId]),
[manifest.projectId, projectPath],
);
+ useEffect(() => {
+ const transitionToken =
+ resourceBookTransitionControllerRef.current.invalidate();
+ resourceBookMainViewportRef.current = { x: 0, y: 0, scale: 1 };
+ setResourceBookMainViewport({ x: 0, y: 0, scale: 1 });
+ dispatchResourceBook({
+ type: 'reset',
+ token: transitionToken,
+ });
+ }, [resourceCategoryScopeKey]);
+ const resourcePageCategoryKey = useMemo(
+ () => createResourceCanvasPageCategorySignature(canvasResources),
+ [canvasResources],
+ );
const resourcePageCategories = useMemo(
- () => (canvasResources.length > 0 ? visibleCategoryOrder : []),
- [canvasResources.length],
+ () => (resourcePageCategoryKey ? [...visibleCategoryOrder] : []),
+ [resourcePageCategoryKey],
);
const activePageCategory =
resourcePageCategories.find(
@@ -1525,6 +1895,89 @@ export default function ProjectDevelopmentView({
) ??
resourcePageCategories[0] ??
null;
+
+ const registerResourceBookThumbnail = useCallback(
+ (category: ResourceCategory, element: HTMLElement | null) => {
+ if (element) {
+ resourceBookThumbnailElementsRef.current.set(category, element);
+ } else {
+ resourceBookThumbnailElementsRef.current.delete(category);
+ }
+ },
+ [],
+ );
+
+ const measureResourceBookOverview = useCallback(() => {
+ const manager = resourceBookManagerRef.current;
+ if (!manager) {
+ return;
+ }
+ const managerRect = manager.getBoundingClientRect();
+ setResourceBookSceneSize((current) =>
+ Math.abs(current.width - managerRect.width) < 0.5 &&
+ Math.abs(current.height - managerRect.height) < 0.5
+ ? current
+ : { width: managerRect.width, height: managerRect.height },
+ );
+ const next = new Map();
+ resourceBookThumbnailElementsRef.current.forEach((element, category) => {
+ const rect = element.getBoundingClientRect();
+ // Read layout coordinates, not the translated/scaled main canvas.
+ const main = element.closest('.game-resource-book-main');
+ const transformed = !main?.classList.contains('is-background');
+ const viewport = transformed
+ ? resourceBookMainViewportRef.current
+ : DEFAULT_RESOURCE_BOOK_VIEWPORT;
+ next.set(category, {
+ left: (rect.left - managerRect.left - viewport.x) / viewport.scale,
+ top: (rect.top - managerRect.top - viewport.y) / viewport.scale,
+ width: rect.width / viewport.scale,
+ height: rect.height / viewport.scale,
+ });
+ });
+ setResourceBookOverviewRects((current) => {
+ if (current.size === next.size) {
+ let same = true;
+ next.forEach((rect, category) => {
+ const previous = current.get(category);
+ if (
+ !previous ||
+ Math.abs(previous.left - rect.left) > 0.5 ||
+ Math.abs(previous.top - rect.top) > 0.5 ||
+ Math.abs(previous.width - rect.width) > 0.5 ||
+ Math.abs(previous.height - rect.height) > 0.5
+ ) {
+ same = false;
+ }
+ });
+ if (same) {
+ return current;
+ }
+ }
+ return next;
+ });
+ }, []);
+
+ useLayoutEffect(() => {
+ measureResourceBookOverview();
+ const manager = resourceBookManagerRef.current;
+ if (!manager || typeof ResizeObserver === 'undefined') {
+ const frame = window.requestAnimationFrame(measureResourceBookOverview);
+ return () => window.cancelAnimationFrame(frame);
+ }
+ const observer = new ResizeObserver(measureResourceBookOverview);
+ observer.observe(manager);
+ resourceBookThumbnailElementsRef.current.forEach((element) =>
+ observer.observe(element),
+ );
+ window.addEventListener('resize', measureResourceBookOverview);
+ const frame = window.requestAnimationFrame(measureResourceBookOverview);
+ return () => {
+ observer.disconnect();
+ window.cancelAnimationFrame(frame);
+ window.removeEventListener('resize', measureResourceBookOverview);
+ };
+ }, [measureResourceBookOverview, resourceBookView, canvasResources.length]);
const viewedResourceCategory =
mode === 'resources' ? activePageCategory : null;
useEffect(() => {
@@ -1751,14 +2204,7 @@ export default function ProjectDevelopmentView({
[resourcePageCategories, sortMode],
);
useLayoutEffect(() => {
- const pageWheelState = resourceCanvasPageWheelRef.current;
- renderedResourceCategoryRef.current = activePageCategory;
- if (pageWheelState.activeCategory === null) {
- pageWheelState.activeCategory = activePageCategory;
- }
- if (pageWheelState.activeCategory === activePageCategory) {
- resourceCanvasViewportRef.current = activeResourceCanvasViewport;
- }
+ resourceCanvasViewportRef.current = activeResourceCanvasViewport;
}, [activePageCategory, activeResourceCanvasViewport]);
const selectedResource =
canvasResources.find((resource) => resource.id === selectedResourceId) ??
@@ -1967,18 +2413,6 @@ export default function ProjectDevelopmentView({
[queueResourceSectionZoomScroll, setResourceSectionZoom],
);
- const cancelQueuedResourceCanvasPageWheel = useCallback(() => {
- const state = resourceCanvasPageWheelRef.current;
- if (state.pendingTimerId !== null) {
- window.clearTimeout(state.pendingTimerId);
- }
- state.accumulatedDeltaY = 0;
- state.lastSwitchAt = 0;
- state.pendingStepCount = 0;
- state.pendingTimerId = null;
- state.activeCategory = renderedResourceCategoryRef.current;
- }, []);
-
const cancelResourceCanvasPan = useCallback(() => {
const pan = resourceCanvasPanRef.current;
if (!pan) {
@@ -2005,54 +2439,174 @@ export default function ProjectDevelopmentView({
clearSkippedResourceCardClick();
}, [clearSkippedResourceCardClick]);
- const selectResourceCanvasPage = useCallback(
+ useEffect(() => {
+ const transitionController = resourceBookTransitionControllerRef.current;
+ return () => {
+ transitionController.dispose();
+ };
+ }, []);
+
+ useLayoutEffect(() => {
+ const manager = resourceBookManagerRef.current;
+ if (resourceBookState.phase === 'idle') return;
+ if (!manager || mode !== 'resources') {
+ resourceBookTransitionControllerRef.current.settle();
+ dispatchResourceBook({
+ type: 'finish-transition',
+ token: resourceBookState.token,
+ });
+ return;
+ }
+ resourceBookTransitionControllerRef.current.play(
+ manager,
+ resourceBookState.token,
+ () => {
+ dispatchResourceBook({
+ type: 'finish-transition',
+ token: resourceBookState.token,
+ });
+ },
+ );
+ }, [resourceBookState.token, resourceBookState.phase, mode]);
+
+ const motionLayoutRef = useRef({
+ token: resourceBookState.token,
+ viewport: activeResourceCanvasViewport,
+ overview: resourceBookOverviewRects,
+ positions: resourcePositionById,
+ sizes: resourceCardSizeByResourceId,
+ resources: canvasResources,
+ sortMode,
+ });
+ useLayoutEffect(() => {
+ const current = {
+ token: resourceBookState.token,
+ viewport: activeResourceCanvasViewport,
+ overview: resourceBookOverviewRects,
+ positions: resourcePositionById,
+ sizes: resourceCardSizeByResourceId,
+ resources: canvasResources,
+ sortMode,
+ };
+ const previous = motionLayoutRef.current;
+ motionLayoutRef.current = current;
+ if (
+ previous.token === current.token &&
+ (previous.viewport !== current.viewport ||
+ previous.overview !== current.overview ||
+ previous.positions !== current.positions ||
+ previous.sizes !== current.sizes ||
+ previous.resources !== current.resources ||
+ previous.sortMode !== current.sortMode)
+ ) {
+ resourceBookTransitionControllerRef.current.settle();
+ }
+ }, [
+ resourceBookState.token,
+ activeResourceCanvasViewport,
+ resourceBookOverviewRects,
+ resourcePositionById,
+ resourceCardSizeByResourceId,
+ canvasResources,
+ sortMode,
+ ]);
+
+ const openResourceBookChild = useCallback(
(category: ResourceCategory) => {
cancelResourceCardDrag();
cancelResourceCanvasPan();
- cancelQueuedResourceCanvasPageWheel();
- resourceCanvasPageWheelRef.current.activeCategory = category;
- setActiveResourceCategory(category);
- },
- [
- cancelQueuedResourceCanvasPageWheel,
- cancelResourceCanvasPan,
- cancelResourceCardDrag,
- ],
- );
- const advanceResourceCanvasPage = useCallback(
- (stepOffset: number) => {
- const currentCategory = resourceCanvasPageWheelRef.current.activeCategory;
- const normalizedStepOffset = Math.trunc(stepOffset);
if (
- !currentCategory ||
- resourcePageCategories.length < 2 ||
- !Number.isFinite(stepOffset) ||
- normalizedStepOffset === 0
+ resourceBookState.view === 'child' &&
+ resourceBookState.category === category &&
+ resourceBookState.phase === 'idle'
) {
return;
}
- const index = resourcePageCategories.indexOf(currentCategory);
- const nextIndex =
- (((index + normalizedStepOffset) % resourcePageCategories.length) +
- resourcePageCategories.length) %
- resourcePageCategories.length;
- const nextCategory = resourcePageCategories[nextIndex];
- if (!nextCategory) {
- return;
- }
- cancelResourceCardDrag();
- cancelResourceCanvasPan();
- resourceCanvasPageWheelRef.current.activeCategory = nextCategory;
- startTransition(() => {
- setActiveResourceCategory((currentCategory) =>
- resourceCanvasPageWheelRef.current.activeCategory === nextCategory
- ? nextCategory
- : currentCategory,
- );
+
+ const transitionToken = resourceBookTransitionControllerRef.current.begin(
+ resourceBookManagerRef.current,
+ );
+ dispatchResourceBook({
+ type: 'open-category',
+ category,
+ token: transitionToken,
});
+
+ const categoryFitKey = `${projectPath}\n${manifest.projectId}\n${sortMode}\n${category}`;
+ const fitCanvasSize =
+ resourceBookSceneSize.width > 0 && resourceBookSceneSize.height > 0
+ ? resourceBookSceneSize
+ : {
+ width: RESOURCE_CANVAS_FALLBACK_WIDTH,
+ height: RESOURCE_CANVAS_FALLBACK_HEIGHT,
+ };
+ const fitMaxScale =
+ resourceBookSceneSize.width > 0 && resourceBookSceneSize.height > 0
+ ? RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE
+ : 1;
+ const categoryBounds = resourceCanvasContentBounds(
+ resourcePositionsByCategory.get(category) ?? [],
+ resourceCardSizeByResourceId,
+ );
+ const categoryViewport = resourceCanvasFitKeysRef.current.has(
+ categoryFitKey,
+ )
+ ? resourceCanvasViewportTargetsRef.current[sortMode][category]
+ : keepResourceBookViewportAtReadableScale({
+ viewport: fitResourceCanvasViewportToContent({
+ bounds: categoryBounds,
+ canvasSize: fitCanvasSize,
+ padding: RESOURCE_CANVAS_FIT_PADDING,
+ maxScale: fitMaxScale,
+ }),
+ bounds: categoryBounds,
+ canvasSize: fitCanvasSize,
+ padding: RESOURCE_CANVAS_FIT_PADDING,
+ });
+
+ // Opening a child explicitly establishes the initial viewport for this
+ // sort/category pair. Mark that fit as settled so the paged-canvas
+ // layout effect does not immediately recalculate it from a shared DOM
+ // measurement and overwrite the stored per-category viewport.
+ if (resourceBookSceneSize.width > 0 && resourceBookSceneSize.height > 0) {
+ resourceCanvasFitKeysRef.current.add(categoryFitKey);
+ }
+
+ setResourceCanvasViewport(category, categoryViewport);
+ setActiveResourceCategory(category);
},
- [cancelResourceCanvasPan, cancelResourceCardDrag, resourcePageCategories],
+ [
+ cancelResourceCanvasPan,
+ cancelResourceCardDrag,
+ manifest.projectId,
+ projectPath,
+ resourceBookState,
+ resourceBookSceneSize,
+ resourceCardSizeByResourceId,
+ resourcePositionsByCategory,
+ setResourceCanvasViewport,
+ sortMode,
+ ],
+ );
+
+ const returnToResourceBookMain = useCallback(() => {
+ if (resourceBookState.view !== 'child') {
+ return;
+ }
+ cancelResourceCardDrag();
+ cancelResourceCanvasPan();
+ const transitionToken = resourceBookTransitionControllerRef.current.begin(
+ resourceBookManagerRef.current,
+ );
+ dispatchResourceBook({ type: 'return-to-main', token: transitionToken });
+ }, [cancelResourceCanvasPan, cancelResourceCardDrag, resourceBookState]);
+
+ const selectResourceCanvasPage = useCallback(
+ (category: ResourceCategory) => {
+ openResourceBookChild(category);
+ },
+ [openResourceBookChild],
);
useEffect(() => {
@@ -2064,124 +2618,30 @@ export default function ProjectDevelopmentView({
return undefined;
}
const zoomFrames = resourceSectionZoomFramesRef.current;
- const pageWheelSwitchIntervalMs = 180;
- const pageWheelDeltaThreshold = 20;
- const maxQueuedPageWheelSteps = Math.max(
- 1,
- resourcePageCategories.length - 1,
- );
- const flushPageWheel = () => {
- const state = resourceCanvasPageWheelRef.current;
- if (state.pendingStepCount === 0) {
- return;
- }
- const now = performance.now();
- const waitMs = pageWheelSwitchIntervalMs - (now - state.lastSwitchAt);
- if (waitMs > 0) {
- if (state.pendingTimerId === null) {
- const timerId = window.setTimeout(() => {
- const latestState = resourceCanvasPageWheelRef.current;
- if (latestState.pendingTimerId !== timerId) {
- return;
- }
- latestState.pendingTimerId = null;
- flushPageWheel();
- }, waitMs);
- state.pendingTimerId = timerId;
- }
- return;
- }
- const stepOffset = state.pendingStepCount;
- state.pendingStepCount = 0;
- state.lastSwitchAt = now;
- advanceResourceCanvasPage(stepOffset);
- };
const resolveViewport = (event: Event) => {
const target = event.target;
return target instanceof Element
- ? target.closest('[data-resource-section-scroll]')
- : null;
+ ? (target.closest('[data-resource-section-scroll]') ??
+ resourceCanvasRef.current?.querySelector(
+ `[data-resource-section-scroll="${activePageCategory ?? ''}"]`,
+ ) ??
+ null)
+ : (resourceCanvasRef.current?.querySelector(
+ `[data-resource-section-scroll="${activePageCategory ?? ''}"]`,
+ ) ?? null);
};
const wheel = (event: WheelEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest('[data-resource-wheel-native="true"]')) {
return;
}
- if (resourcePageCategories.length > 0) {
- event.preventDefault();
- if (event.ctrlKey || event.metaKey) {
- cancelQueuedResourceCanvasPageWheel();
- const viewport = resolveViewport(event);
- const category = viewport?.dataset.resourceSectionScroll as
- | ResourceCategory
- | undefined;
- if (!viewport || !category) {
- return;
- }
- const rect = viewport.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);
- return;
- }
- if (!Number.isFinite(event.deltaY) || event.deltaY === 0) {
- return;
- }
- const pageWheelState = resourceCanvasPageWheelRef.current;
- const deltaModeMultiplier =
- event.deltaMode === 1
- ? 16
- : event.deltaMode === 2
- ? Math.max(canvas.clientHeight, 1)
- : 1;
- const pageWheelDeltaPerStep =
- event.deltaMode === 1
- ? 48
- : event.deltaMode === 2
- ? Math.max(canvas.clientHeight, 1)
- : 120;
- const normalizedDeltaY = event.deltaY * deltaModeMultiplier;
- const direction = Math.sign(normalizedDeltaY);
- if (
- (pageWheelState.accumulatedDeltaY !== 0 &&
- direction !== Math.sign(pageWheelState.accumulatedDeltaY)) ||
- (pageWheelState.pendingStepCount !== 0 &&
- direction !== Math.sign(pageWheelState.pendingStepCount))
- ) {
- pageWheelState.accumulatedDeltaY = 0;
- pageWheelState.pendingStepCount = 0;
- }
- pageWheelState.accumulatedDeltaY += normalizedDeltaY;
- if (
- Math.abs(pageWheelState.accumulatedDeltaY) >= pageWheelDeltaThreshold
- ) {
- const stepCount = Math.max(
- 1,
- Math.floor(
- Math.abs(pageWheelState.accumulatedDeltaY) /
- pageWheelDeltaPerStep,
- ),
- );
- pageWheelState.accumulatedDeltaY = 0;
- pageWheelState.pendingStepCount = Math.max(
- -maxQueuedPageWheelSteps,
- Math.min(
- maxQueuedPageWheelSteps,
- pageWheelState.pendingStepCount + direction * stepCount,
- ),
- );
- }
- flushPageWheel();
+ if (
+ resourcePageCategories.length > 0 &&
+ !event.ctrlKey &&
+ !event.metaKey
+ ) {
+ // Ordinary wheel input is handled by the resource-book manager and
+ // pans the active resource view instead of changing sections.
return;
}
if (!event.ctrlKey && !event.metaKey) {
@@ -2206,8 +2666,8 @@ export default function ProjectDevelopmentView({
);
};
const gestureStart = (rawEvent: Event) => {
+ resourceBookTransitionControllerRef.current.settle();
const event = rawEvent as WebKitGestureEvent;
- cancelQueuedResourceCanvasPageWheel();
const viewport = resolveViewport(event);
const category = viewport?.dataset.resourceSectionScroll as
| ResourceCategory
@@ -2277,26 +2737,17 @@ export default function ProjectDevelopmentView({
canvas.removeEventListener('gesturechange', gestureChange);
canvas.removeEventListener('gestureend', gestureEnd);
resourceSectionGestureRef.current = null;
- const pageWheelState = resourceCanvasPageWheelRef.current;
- if (pageWheelState.pendingTimerId !== null) {
- window.clearTimeout(pageWheelState.pendingTimerId);
- }
- pageWheelState.accumulatedDeltaY = 0;
- pageWheelState.pendingStepCount = 0;
- pageWheelState.pendingTimerId = null;
for (const pending of zoomFrames.values()) {
window.cancelAnimationFrame(pending.frameId);
}
zoomFrames.clear();
};
}, [
- advanceResourceCanvasPage,
applyAnchoredResourceSectionZoom,
- cancelQueuedResourceCanvasPageWheel,
+ activePageCategory,
mode,
queueResourceSectionZoomScroll,
resourcePageCategories.length,
- setResourceCanvasViewport,
setResourceSectionZoom,
sortMode,
]);
@@ -2319,11 +2770,20 @@ export default function ProjectDevelopmentView({
viewportElement?.clientHeight || rect?.height || 0;
if (measuredWidth > 0 && measuredHeight > 0) {
resourceCanvasFitKeysRef.current.add(fitKey);
+ const canvasSize = {
+ width: measuredWidth,
+ height: measuredHeight,
+ };
setResourceCanvasViewport(
activePageCategory,
- fitResourceCanvasViewportToContent({
+ keepResourceBookViewportAtReadableScale({
+ viewport: fitResourceCanvasViewportToContent({
+ bounds: resourceCanvasFitBounds,
+ canvasSize,
+ padding: RESOURCE_CANVAS_FIT_PADDING,
+ }),
bounds: resourceCanvasFitBounds,
- canvasSize: { width: measuredWidth, height: measuredHeight },
+ canvasSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
}),
);
@@ -2332,10 +2792,11 @@ export default function ProjectDevelopmentView({
}
// Resource extents can change after an image finishes measuring or a
// card is manually moved. Keep the user's infinite pan/zoom unchanged.
- setResourceCanvasViewport(
- activePageCategory,
- resourceCanvasViewportRef.current,
- );
+ const preservedViewport =
+ resourceCanvasViewportTargetsRef.current[sortMode][
+ activePageCategory
+ ] ?? activeResourceCanvasViewport;
+ setResourceCanvasViewport(activePageCategory, preservedViewport);
};
fitInitialViewportOrPreserve();
const observer = window.ResizeObserver
@@ -2354,6 +2815,8 @@ export default function ProjectDevelopmentView({
mode,
projectPath,
resourceCanvasFitBounds,
+ activeResourceCanvasViewport,
+ resourceCanvasViewportTargetsRef,
setResourceCanvasViewport,
sortMode,
]);
@@ -2499,16 +2962,6 @@ export default function ProjectDevelopmentView({
setResourceCanvasViewports(defaultViewports);
resourceCanvasFitKeysRef.current.clear();
resourceCanvasViewportRef.current = defaultViewports.dependency.document;
- if (resourceCanvasPageWheelRef.current.pendingTimerId !== null) {
- window.clearTimeout(resourceCanvasPageWheelRef.current.pendingTimerId);
- }
- resourceCanvasPageWheelRef.current = {
- activeCategory: null,
- accumulatedDeltaY: 0,
- lastSwitchAt: 0,
- pendingStepCount: 0,
- pendingTimerId: null,
- };
cancelResourceCardDrag();
resourceCanvasPanRef.current = null;
resourceDependencyOverlayRef.current?.clearDragPreview();
@@ -2919,6 +3372,7 @@ export default function ProjectDevelopmentView({
);
const resetResourceCanvasViewport = useCallback(() => {
+ resourceBookTransitionControllerRef.current.settle();
if (!activePageCategory) {
return;
}
@@ -2929,10 +3383,183 @@ export default function ProjectDevelopmentView({
bounds: resourceCanvasFitBounds,
canvasSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
+ maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE,
}),
);
}, [activePageCategory, resourceCanvasFitBounds, setResourceCanvasViewport]);
+ const fitResourceBook = useCallback(() => {
+ resourceBookTransitionControllerRef.current.settle();
+ if (resourceBookView === 'main') {
+ const resetViewport = DEFAULT_RESOURCE_BOOK_VIEWPORT;
+ resourceBookMainViewportRef.current = resetViewport;
+ setResourceBookMainViewport(resetViewport);
+ return;
+ }
+ if (!activePageCategory) {
+ return;
+ }
+ setResourceCanvasViewport(
+ activePageCategory,
+ fitResourceCanvasViewportToContent({
+ bounds: resourceCanvasFitBounds,
+ canvasSize: resourceBookSceneSize,
+ padding: RESOURCE_CANVAS_FIT_PADDING,
+ maxScale: RESOURCE_CANVAS_BOOK_FIT_MAX_SCALE,
+ }),
+ );
+ }, [
+ activePageCategory,
+ resourceBookSceneSize,
+ resourceBookView,
+ resourceCanvasFitBounds,
+ setResourceCanvasViewport,
+ ]);
+
+ const zoomResourceBookBy = useCallback(
+ (factor: number) => {
+ resourceBookTransitionControllerRef.current.settle();
+ const sceneElement = resourceBookManagerRef.current;
+ const rect = sceneElement?.getBoundingClientRect();
+ const anchorX = rect ? rect.width / 2 : 0;
+ const anchorY = rect ? rect.height / 2 : 0;
+ if (resourceBookView === 'main') {
+ const viewport = resourceBookMainViewportRef.current;
+ const next = zoomResourceBookViewport(viewport, factor, {
+ x: anchorX,
+ y: anchorY,
+ });
+ if (next.scale === viewport.scale) return;
+ resourceBookMainViewportRef.current = next;
+ setResourceBookMainViewport(next);
+ return;
+ }
+ const category = activePageCategory;
+ if (!category) {
+ return;
+ }
+ const viewport = resourceCanvasViewportRef.current;
+ const next = zoomResourceBookViewport(viewport, factor, {
+ x: anchorX,
+ y: anchorY,
+ });
+ if (next.scale === viewport.scale) return;
+ setResourceCanvasViewport(category, next);
+ },
+ [activePageCategory, resourceBookView, setResourceCanvasViewport],
+ );
+
+ const handleResourceBookWheel = useCallback(
+ (event: ReactWheelEvent | WheelEvent) => {
+ resourceBookTransitionControllerRef.current.settle();
+ const sceneElement = resourceBookManagerRef.current;
+ if (!sceneElement) {
+ return;
+ }
+ event.preventDefault();
+ const rect = sceneElement.getBoundingClientRect();
+ const screenPoint = {
+ x: event.clientX - rect.left,
+ y: event.clientY - rect.top,
+ };
+ if (resourceBookView === 'main') {
+ const current = resourceBookMainViewportRef.current;
+ const result = resolveResourceBookWheelViewport({
+ viewport: current,
+ deltaX: event.deltaX,
+ deltaY: event.deltaY,
+ shiftKey: event.shiftKey,
+ ctrlKey: event.ctrlKey,
+ metaKey: event.metaKey,
+ screenPoint,
+ });
+ resourceBookMainViewportRef.current = result.viewport;
+ setResourceBookMainViewport(result.viewport);
+ return;
+ }
+ const category = activePageCategory;
+ if (!category) {
+ return;
+ }
+ const result = resolveResourceBookWheelViewport({
+ viewport: resourceCanvasViewportRef.current,
+ deltaX: event.deltaX,
+ deltaY: event.deltaY,
+ shiftKey: event.shiftKey,
+ ctrlKey: event.ctrlKey,
+ metaKey: event.metaKey,
+ screenPoint,
+ });
+ setResourceCanvasViewport(category, result.viewport, true);
+ },
+ [activePageCategory, resourceBookView, setResourceCanvasViewport],
+ );
+
+ useEffect(() => {
+ const manager = resourceBookManagerRef.current;
+ if (!manager) return;
+ // React wheel listeners are passive; canvas gestures must suppress native
+ // scrolling/zooming even when all content has been panned off screen.
+ manager.addEventListener('wheel', handleResourceBookWheel, {
+ passive: false,
+ });
+ return () => manager.removeEventListener('wheel', handleResourceBookWheel);
+ }, [handleResourceBookWheel, mode]);
+
+ const handleResourceBookMainPointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ if (resourceBookView !== 'main' || event.button !== 0) {
+ return;
+ }
+ const target = event.target as HTMLElement;
+ if (
+ target.closest(
+ 'button, input, textarea, select, a, .game-resource-book-thumbnail, .game-resource-book-zoom',
+ )
+ ) {
+ return;
+ }
+ event.preventDefault();
+ event.currentTarget.setPointerCapture?.(event.pointerId);
+ resourceBookTransitionControllerRef.current.settle();
+ resourceBookMainPanRef.current = {
+ pointerId: event.pointerId,
+ startClientX: event.clientX,
+ startClientY: event.clientY,
+ startViewport: resourceBookMainViewportRef.current,
+ };
+ },
+ [resourceBookView],
+ );
+
+ const handleResourceBookMainPointerMove = useCallback(
+ (event: ReactPointerEvent) => {
+ const pan = resourceBookMainPanRef.current;
+ if (!pan || pan.pointerId !== event.pointerId) {
+ return;
+ }
+ const next = panResourceBookViewport(pan.startViewport, {
+ x: event.clientX - pan.startClientX,
+ y: event.clientY - pan.startClientY,
+ });
+ resourceBookMainViewportRef.current = next;
+ setResourceBookMainViewport(next);
+ },
+ [],
+ );
+
+ const stopResourceBookMainPan = useCallback(
+ (event: ReactPointerEvent) => {
+ if (resourceBookMainPanRef.current?.pointerId === event.pointerId) {
+ resourceBookMainPanRef.current = null;
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
+ }
+ }
+ },
+ [],
+ );
+
const handleResourceCardPointerDown = useCallback(
(
event: ReactPointerEvent,
@@ -2959,7 +3586,6 @@ export default function ProjectDevelopmentView({
if (!Number.isFinite(scale) || scale <= 0) {
return;
}
- cancelQueuedResourceCanvasPageWheel();
event.currentTarget.setPointerCapture?.(event.pointerId);
resourceCardDragRef.current = {
pointerId: event.pointerId,
@@ -2974,7 +3600,7 @@ export default function ProjectDevelopmentView({
changed: false,
};
},
- [activePageCategory, cancelQueuedResourceCanvasPageWheel],
+ [activePageCategory],
);
const handleResourceCardPointerMove = useCallback(
@@ -2996,7 +3622,7 @@ export default function ProjectDevelopmentView({
}
drag.changed = true;
const coordinate = (start: number, delta: number) =>
- normalizeResourceCanvasDragCoordinate(start + delta / drag.startScale);
+ start + delta / drag.startScale;
const preview = {
resourceId: drag.resourceId,
x: coordinate(drag.startX, deltaX),
@@ -3027,14 +3653,12 @@ export default function ProjectDevelopmentView({
const preview = drag.changed
? {
resourceId: drag.resourceId,
- x: normalizeResourceCanvasDragCoordinate(
+ x:
drag.startX +
- (event.clientX - drag.startClientX) / drag.startScale,
- ),
- y: normalizeResourceCanvasDragCoordinate(
+ (event.clientX - drag.startClientX) / drag.startScale,
+ y:
drag.startY +
- (event.clientY - drag.startClientY) / drag.startScale,
- ),
+ (event.clientY - drag.startClientY) / drag.startScale,
}
: null;
setResourceCardDragPreview(null);
@@ -3076,13 +3700,13 @@ export default function ProjectDevelopmentView({
const target = event.target as HTMLElement;
if (
target.closest(
- 'button, input, textarea, select, a, .game-resource-card',
+ 'button, input, textarea, select, a, .game-resource-card, .game-resource-book-scene-titlebar, .game-resource-book-zoom',
)
) {
return;
}
- cancelQueuedResourceCanvasPageWheel();
event.currentTarget.setPointerCapture(event.pointerId);
+ resourceBookTransitionControllerRef.current.settle();
resourceCanvasPanRef.current = {
pointerId: event.pointerId,
category: activePageCategory,
@@ -3091,7 +3715,7 @@ export default function ProjectDevelopmentView({
startViewport: resourceCanvasViewportRef.current,
};
},
- [activePageCategory, cancelQueuedResourceCanvasPageWheel],
+ [activePageCategory],
);
const handleResourceCanvasPointerMove = useCallback(
@@ -3129,10 +3753,10 @@ export default function ProjectDevelopmentView({
if (nextMode === sortMode) {
return;
}
+ resourceBookTransitionControllerRef.current.settle();
advanceFocusGeneration();
cancelResourceCardDrag();
cancelResourceCanvasPan();
- cancelQueuedResourceCanvasPageWheel();
captureResourceListScrollPosition();
captureResourceSectionScrollPositions();
setSortMode(nextMode);
@@ -4156,7 +4780,7 @@ export default function ProjectDevelopmentView({
: false;
const card = intent.resourceId
? Array.from(
- resourceCanvasRef.current?.querySelectorAll(
+ resourceBookManagerRef.current?.querySelectorAll(
'[data-resource-id]',
) ?? [],
).find((element) => element.dataset.resourceId === intent.resourceId)
@@ -4270,6 +4894,92 @@ export default function ProjectDevelopmentView({
!resourceEditorRoute &&
!uiEditorRoute;
+ const renderResourceBookCard = useCallback(
+ (
+ resource: ProjectResource,
+ layout: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ rotation: number;
+ dragX: number;
+ dragY: number;
+ },
+ ) => {
+ const previewIdentity = resourceCardPreviews.identityByResourceId.get(
+ resource.id,
+ );
+ if (!previewIdentity) {
+ return null;
+ }
+ const dragPreview =
+ resourceCardDragPreview?.resourceId === resource.id
+ ? resourceCardDragPreview
+ : null;
+ const previewX = dragPreview ? dragPreview.x : undefined;
+ const previewY = dragPreview ? dragPreview.y : undefined;
+ return (
+
+ handleResourceCardPointerDown(
+ event,
+ resource,
+ layout.dragX,
+ layout.dragY,
+ )
+ }
+ onPointerMove={handleResourceCardPointerMove}
+ onPointerUp={handleResourceCardPointerUp}
+ onPointerCancel={handleResourceCardPointerCancel}
+ onObservePreview={resourceCardPreviews.observePreview}
+ onRequestPreview={resourceCardPreviews.requestPreview}
+ onPreviewDecodeError={handleCardPreviewDecodeError}
+ onMediaToggle={handleCardMediaToggle}
+ onMediaReady={handleCardMediaReady}
+ onMediaPlaybackChange={handleCardMediaPlaybackChange}
+ />
+ );
+ },
+ [
+ activeCardMediaIdentity,
+ handleCardMediaPlaybackChange,
+ handleCardMediaReady,
+ handleCardMediaToggle,
+ handleCardPreviewDecodeError,
+ handleResourceCardOpenEditor,
+ handleResourceCardPointerCancel,
+ handleResourceCardPointerDown,
+ handleResourceCardPointerMove,
+ handleResourceCardPointerUp,
+ handleResourceSelect,
+ resourceCardDragPreview,
+ resourceCardPreviews,
+ selectedResourceId,
+ selectedVersionBindingResourceIds,
+ ],
+ );
+
return (
) : null}
-
+
{
+ if (
+ event.currentTarget.hasAttribute('data-book-motion') &&
+ (event.target as Element).closest('.game-resource-card')
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ resourceBookTransitionControllerRef.current.settle();
+ }
+ }}
+ >
+
+
+
+
+
+ {Math.round(
+ (resourceBookState.view === 'main'
+ ? resourceBookMainViewport.scale
+ : activeResourceCanvasViewport.scale) * 100,
+ )}
+ %
+
+
+
+