@ash-qw/dsh-theme-prts 0.1.92 → 0.1.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -326,7 +326,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
326
326
  const rhodesIndex = Math.max(0, emblemSequence.findIndex(entry => entry.key === 'rhodes-island'))
327
327
  const suppliedHeroMask = normalizeEmblemMasks(heroEmblemMask ? [heroEmblemMask] : [], window).get('rhodes-island-hero')
328
328
  const empty = { mounted: false, hero: false, side: -1, transition: null, anchor: { horizontalProgress: 0, verticalProgress: 0 }, phase: 'error', error: '徽记资源异常' }
329
- if (!document || !window) return { update() {}, inspect: () => empty, dispose() {} }
329
+ if (!document || !window) return { update() {}, setSchemeTransitionActive() {}, inspect: () => empty, dispose() {} }
330
330
 
331
331
  let preferences = {}
332
332
  let operation
@@ -378,6 +378,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
378
378
  let side = -1
379
379
  let segment = 0
380
380
  let transition
381
+ let traversalOffset = 0
381
382
  let pendingSegment
382
383
  let transitionStartedAt
383
384
  let densityTransition
@@ -394,6 +395,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
394
395
  let targetPrewarmQueue = []
395
396
  let pauseStartedAt
396
397
  let hydrationPaused = false
398
+ let schemeTransitionPaused = false
397
399
  let hydrationTimer
398
400
  let hydrationFrame
399
401
  let hydrationStableFrames = 0
@@ -427,7 +429,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
427
429
  : preferences.conversationParticleDensity
428
430
  const densityProfile = density => PARTICLE_DENSITY_PROFILES[density] ?? PARTICLE_DENSITY_PROFILES.standard
429
431
  const particleRadius = density => densityProfile(density ?? activeDensity()).radius
430
- const canRenderFrame = () => documentVisible && canvasVisible && !hydrationPaused && !resizing
432
+ const canRenderFrame = () => !disposed && documentVisible && canvasVisible && !hydrationPaused && !schemeTransitionPaused && !resizing
431
433
  const layoutWidth = () => width
432
434
  const layoutHeight = () => height
433
435
  const layoutShiftX = () => 0
@@ -554,11 +556,51 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
554
556
  }
555
557
 
556
558
 
559
+ function particleTraversalSpeed(value = preferences) {
560
+ if (phoneStatic()) return 0
561
+ const numeric = Number(value?.particleTraversalSpeed)
562
+ return Number.isFinite(numeric) ? clamp(numeric, 0, 2) : 1
563
+ }
564
+
565
+ function currentScrollTop() {
566
+ return Number(scroller?.scrollTop) || 0
567
+ }
568
+
569
+ function traversalViewport() {
570
+ return Math.max(1, Number(scroller?.clientHeight) || height)
571
+ }
572
+
573
+ function traversalCoordinate(value = preferences) {
574
+ return traversalOffset + currentScrollTop() * particleTraversalSpeed(value) / traversalViewport()
575
+ }
576
+
557
577
  function scrollProgress() {
558
578
  if (!scroller) return 0
559
- const viewport = Math.max(1, Number(scroller.clientHeight) || height)
560
- const raw = (Number(scroller.scrollTop) || 0) / viewport
561
- return raw - Math.floor(raw)
579
+ const coordinate = traversalCoordinate()
580
+ return coordinate - Math.floor(coordinate)
581
+ }
582
+
583
+ function restoreTraversalFromScroll() {
584
+ traversalOffset = 0
585
+ const coordinate = traversalCoordinate()
586
+ segment = Math.floor(coordinate)
587
+ side = sideForSegment(segment)
588
+ pendingSegment = undefined
589
+ transition = undefined
590
+ transitionStartedAt = undefined
591
+ }
592
+
593
+ function reanchorTraversal(coordinate = traversalCoordinate()) {
594
+ if (transition) {
595
+ const targetSegment = transition.targetSegment
596
+ side = sideForSegment(targetSegment)
597
+ transition = undefined
598
+ transitionStartedAt = undefined
599
+ commitTransitionTargets(targetSegment, true)
600
+ }
601
+ pendingSegment = undefined
602
+ traversalOffset = coordinate - currentScrollTop() * particleTraversalSpeed() / traversalViewport()
603
+ if (particles.length) snapParticlesToCurrentLayout()
562
604
  }
563
605
 
564
606
  function anchorFor(sideValue, verticalProgress) {
@@ -1082,6 +1124,19 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1082
1124
  const entry = entries.find(candidate => candidate.target === operation) ?? entries[0]
1083
1125
  const contentRect = entry?.contentRect
1084
1126
  pendingResizeSize = contentRect ? sizeFromRect(contentRect) : readOperationSize()
1127
+ if (phoneStatic()) {
1128
+ const nextSize = pendingResizeSize
1129
+ clearResizeState()
1130
+ const changed = applyFieldSize(nextSize)
1131
+ if (changed && particles.length) snapParticlesToCurrentLayout()
1132
+ resizeCommits += changed ? 1 : 0
1133
+ syncCanvasBackingStore()
1134
+ geometryDirty = false
1135
+ cancelFrame()
1136
+ draw(Number(window.performance?.now?.()) || 0)
1137
+ cancelFrame()
1138
+ return
1139
+ }
1085
1140
  if (resizeFrame !== undefined) return
1086
1141
  resizeFrame = window.requestAnimationFrame?.(() => {
1087
1142
  resizeFrame = undefined
@@ -1373,6 +1428,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1373
1428
  return
1374
1429
  }
1375
1430
  const targetSide = sideForSegment(targetSegment)
1431
+ const direction = targetSegment > segment ? 1 : -1
1376
1432
  if (targetSide === side || motionReduced() || phoneStatic()) {
1377
1433
  side = targetSide
1378
1434
  transition = undefined
@@ -1389,7 +1445,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1389
1445
  ...population,
1390
1446
  fromSide: side,
1391
1447
  toSide: targetSide,
1392
- direction: targetSegment > segment ? 1 : -1,
1448
+ direction,
1393
1449
  targetSegment,
1394
1450
  progress: 0,
1395
1451
  }
@@ -1398,9 +1454,27 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1398
1454
  scheduleFrame()
1399
1455
  }
1400
1456
 
1457
+ function retargetTransition(targetSegment) {
1458
+ if (!transition || targetSegment === transition.targetSegment) return
1459
+ const population = prepareTransitionTargets(targetSegment)
1460
+ if (!population.toCount) {
1461
+ reportState({ phase: 'error', error: '徽记资源异常' })
1462
+ return
1463
+ }
1464
+ transition = {
1465
+ ...transition,
1466
+ ...population,
1467
+ toSide: sideForSegment(targetSegment),
1468
+ direction: targetSegment >= segment ? 1 : -1,
1469
+ targetSegment,
1470
+ }
1471
+ pendingSegment = undefined
1472
+ }
1473
+
1401
1474
  function resetTraversal() {
1402
1475
  side = -1
1403
1476
  segment = 0
1477
+ traversalOffset = -currentScrollTop() * particleTraversalSpeed() / traversalViewport()
1404
1478
  pendingSegment = undefined
1405
1479
  queuedDensityCount = undefined
1406
1480
  queuedTargetRefresh = false
@@ -1430,6 +1504,9 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1430
1504
  heroMarker = undefined
1431
1505
  heroActive = false
1432
1506
  scroller?.removeAttribute('data-prts-hero-active')
1507
+ side = -1
1508
+ segment = 0
1509
+ traversalOffset = -currentScrollTop() * particleTraversalSpeed() / traversalViewport()
1433
1510
  pendingSegment = undefined
1434
1511
  queuedDensityCount = undefined
1435
1512
  queuedTargetRefresh = false
@@ -1469,14 +1546,13 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1469
1546
  if (heroActive) leaveHero()
1470
1547
  }
1471
1548
  function onScroll() {
1472
- if (!scroller) return
1549
+ if (!scroller || phoneStatic()) return
1473
1550
  if (heroActive) {
1474
1551
  scheduleFrame()
1475
1552
  return
1476
1553
  }
1477
- const viewport = Math.max(1, Number(scroller.clientHeight) || height)
1478
- const desiredSegment = Math.floor(((Number(scroller.scrollTop) || 0) + 1) / viewport)
1479
- if (transition) pendingSegment = desiredSegment
1554
+ const desiredSegment = Math.floor(traversalCoordinate())
1555
+ if (transition) retargetTransition(desiredSegment)
1480
1556
  else if (desiredSegment !== segment) startTransition(desiredSegment)
1481
1557
  scheduleFrame()
1482
1558
  }
@@ -1519,6 +1595,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1519
1595
  }
1520
1596
 
1521
1597
  function registerContentMutations(mutations) {
1598
+ if (phoneStatic()) return
1522
1599
  let weight = mutations.length
1523
1600
  for (const mutation of mutations) {
1524
1601
  for (const node of [...mutation.addedNodes, ...mutation.removedNodes]) {
@@ -1631,8 +1708,35 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1631
1708
  cancelFrame()
1632
1709
  }
1633
1710
 
1711
+ function rebindScroller(nextScroller) {
1712
+ scroller?.removeAttribute('data-prts-hero-active')
1713
+ scroller?.removeEventListener('scroll', onScroll)
1714
+ cancelHydrationResume()
1715
+ hydrationPaused = false
1716
+ mutationBurstScore = 0
1717
+ if (mutationBurstTimer !== undefined) window.clearTimeout?.(mutationBurstTimer)
1718
+ mutationBurstTimer = undefined
1719
+ heroMarker = undefined
1720
+ heroActive = false
1721
+ scroller = nextScroller
1722
+ scroller.addEventListener('scroll', onScroll, { passive: true })
1723
+ syncHeroState()
1724
+ if (!heroActive) {
1725
+ resetTraversal()
1726
+ if (!phoneStatic()) restoreTraversalFromScroll()
1727
+ commitTransitionTargets(segment, true)
1728
+ }
1729
+ scheduleFrame()
1730
+ }
1731
+
1634
1732
  function mount(nextOperation, nextScroller) {
1635
- if (operation === nextOperation && scroller === nextScroller && ambient?.isConnected && canvas?.isConnected) return
1733
+ if (operation === nextOperation && ambient?.isConnected && canvas?.isConnected) {
1734
+ if (scroller === nextScroller) return
1735
+ if (nextScroller) {
1736
+ rebindScroller(nextScroller)
1737
+ return
1738
+ }
1739
+ }
1636
1740
  unmount()
1637
1741
  operation = nextOperation
1638
1742
  scroller = nextScroller
@@ -1643,12 +1747,13 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1643
1747
  glow = created.glow
1644
1748
  canvas = document.createElement('canvas')
1645
1749
  canvas.setAttribute('data-prts-particle-layer', '')
1646
- canvas.setAttribute('aria-label', '随对话滚动轮换阵营标识的粒子画布')
1750
+ canvas.setAttribute('aria-label', phoneStatic() ? '静态阵营标识粒子画布' : '随对话滚动轮换阵营标识的粒子画布')
1647
1751
  canvas.style.visibility = 'hidden'
1648
1752
  if (typeof window.CanvasRenderingContext2D === 'function') context = canvas.getContext('2d')
1649
1753
  targetCache.clear()
1650
1754
  initializeFieldSize()
1651
1755
  syncHeroState()
1756
+ if (!heroActive) restoreTraversalFromScroll()
1652
1757
  if (!particles.length && maskRecords.length) buildParticles()
1653
1758
  if (context && particles.length) {
1654
1759
  syncCanvasBackingStore()
@@ -1714,8 +1819,19 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1714
1819
  if (!mediaQuery?.addEventListener) mediaQuery?.addListener?.(mediaListener)
1715
1820
  document.addEventListener?.('visibilitychange', onVisibilityChange)
1716
1821
  return {
1822
+ setSchemeTransitionActive(active) {
1823
+ const next = Boolean(active)
1824
+ if (schemeTransitionPaused === next) return
1825
+ schemeTransitionPaused = next
1826
+ updateFrameGate()
1827
+ },
1717
1828
  update(nextPreferences = {}) {
1718
1829
  const previousPreferences = preferences
1830
+ const traversalSpeedChanged = previousPreferences.particleTraversalSpeed !== undefined
1831
+ && particleTraversalSpeed(previousPreferences) !== particleTraversalSpeed(nextPreferences)
1832
+ const preservedTraversalCoordinate = traversalSpeedChanged && scroller && !heroActive
1833
+ ? traversalCoordinate(previousPreferences)
1834
+ : undefined
1719
1835
  preferences = nextPreferences
1720
1836
  disposed = false
1721
1837
  if (!preferences.enabled) {
@@ -1734,6 +1850,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1734
1850
  const activeDensityChanged = previousActiveDensity !== undefined && previousActiveDensity !== activeDensity()
1735
1851
  reportState({ requestedPattern: 'orthogonal' })
1736
1852
  if (particles.length && activeDensityChanged) scheduleDensityReassembly(targetParticleCount(), true)
1853
+ if (preservedTraversalCoordinate !== undefined && scroller && !heroActive) reanchorTraversal(preservedTraversalCoordinate)
1737
1854
  if (!mutationObserver && window.MutationObserver && document.body) {
1738
1855
  const observationRoot = document.querySelector('[data-slot="root"]')
1739
1856
  ?? document.querySelector('[data-prts-region="frame"]')
@@ -1771,6 +1888,10 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1771
1888
  emblemKey: (heroActive || transition?.fromHero ? heroEmblem() : emblemForSegment(segment))?.key,
1772
1889
  nextEmblemKey: transition ? emblemForSegment(transition.targetSegment)?.key : null,
1773
1890
  segment,
1891
+ traversalSpeed: particleTraversalSpeed(),
1892
+ traversalOffset,
1893
+ traversalCoordinate: traversalCoordinate(),
1894
+ traversalProgress: scrollProgress(),
1774
1895
  transition: transition ? {
1775
1896
  fromSide: transition.fromSide,
1776
1897
  toSide: transition.toSide,
@@ -1787,6 +1908,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1787
1908
  firstFrameReady: canvas?.hasAttribute('data-prts-particle-ready') === true,
1788
1909
  hydrationPaused,
1789
1910
  sleeping: Boolean(context && particles.length && frame === undefined && canRenderFrame() && !motionReduced() && !phoneStatic()),
1911
+ schemeTransitionPaused,
1790
1912
  pointer: {
1791
1913
  active: pointerActive,
1792
1914
  clientX: pointerClientX,
@@ -10,8 +10,13 @@ export const CONVERSATION_SCALE_FOCUS_CONTRAST_MAX = 100
10
10
  export const CONVERSATION_SCALE_FOCUS_CONTRAST_STEP = 10
11
11
  export const CONVERSATION_SCALE_FOCUS_CONTRAST_DEFAULT = 70
12
12
 
13
+ export const PARTICLE_TRAVERSAL_SPEED_MIN = 0
14
+ export const PARTICLE_TRAVERSAL_SPEED_MAX = 2
15
+ export const PARTICLE_TRAVERSAL_SPEED_STEP = 0.25
16
+ export const PARTICLE_TRAVERSAL_SPEED_DEFAULT = 1
17
+
13
18
  export const DEFAULT_PREFERENCES = Object.freeze({
14
- version: 6,
19
+ version: 8,
15
20
  enabled: false,
16
21
  preset: 'standard-tactical',
17
22
  texture: 'full',
@@ -20,7 +25,9 @@ export const DEFAULT_PREFERENCES = Object.freeze({
20
25
  bootAnimation: true,
21
26
  conversationParticleDensity: 'sparse',
22
27
  heroParticleDensity: 'light',
28
+ particleTraversalSpeed: PARTICLE_TRAVERSAL_SPEED_DEFAULT,
23
29
  particlePattern: 'orthogonal',
30
+ railDefaultHidden: false,
24
31
  conversationScaleMaxDistance: CONVERSATION_SCALE_DISTANCE_DEFAULT,
25
32
  conversationScaleFocusContrast: CONVERSATION_SCALE_FOCUS_CONTRAST_DEFAULT,
26
33
  })
@@ -60,10 +67,10 @@ export const PARTICLE_DETAIL_LEVELS = Object.freeze({
60
67
  })
61
68
  export const PREFERENCE_GROUPS = Object.freeze({
62
69
  background: Object.freeze(['texture']),
63
- particles: Object.freeze(['conversationParticleDensity', 'heroParticleDensity']),
70
+ particles: Object.freeze(['conversationParticleDensity', 'heroParticleDensity', 'particleTraversalSpeed']),
64
71
  material: Object.freeze(['glass']),
65
72
  accessibility: Object.freeze(['motion', 'bootAnimation']),
66
- navigation: Object.freeze(['conversationScaleMaxDistance', 'conversationScaleFocusContrast']),
73
+ navigation: Object.freeze(['railDefaultHidden', 'conversationScaleMaxDistance', 'conversationScaleFocusContrast']),
67
74
  })
68
75
 
69
76
  const TEXTURES = new Set(['off', 'restrained', 'full'])
@@ -114,6 +121,13 @@ function scaleFocusContrastOr(value, fallback) {
114
121
  return Math.round(clamped / CONVERSATION_SCALE_FOCUS_CONTRAST_STEP) * CONVERSATION_SCALE_FOCUS_CONTRAST_STEP
115
122
  }
116
123
 
124
+ function particleTraversalSpeedOr(value, fallback) {
125
+ const numeric = Number(value)
126
+ if (!Number.isFinite(numeric)) return fallback
127
+ const clamped = Math.min(PARTICLE_TRAVERSAL_SPEED_MAX, Math.max(PARTICLE_TRAVERSAL_SPEED_MIN, numeric))
128
+ return Math.round(clamped / PARTICLE_TRAVERSAL_SPEED_STEP) * PARTICLE_TRAVERSAL_SPEED_STEP
129
+ }
130
+
117
131
  function normalizeGlass(input) {
118
132
  if (input.glassEnabled === false || input.glass === 'off') return 'off'
119
133
  if (input.glass === 'liquid') return 'clear'
@@ -141,7 +155,7 @@ export function normalizePreferences(value) {
141
155
  const particlePreset = VISUAL_PRESETS[input.preset] ?? DEFAULT_PREFERENCES
142
156
  const legacyDensities = legacyParticleDensities(input)
143
157
  const normalized = {
144
- version: 6,
158
+ version: 8,
145
159
  enabled: booleanOr(input.enabled, DEFAULT_PREFERENCES.enabled),
146
160
  preset: enumOr(migratedPreset, PRESETS, DEFAULT_PREFERENCES.preset),
147
161
  texture: enumOr(input.texture, TEXTURES, DEFAULT_PREFERENCES.texture),
@@ -150,7 +164,12 @@ export function normalizePreferences(value) {
150
164
  bootAnimation: booleanOr(input.bootAnimation, DEFAULT_PREFERENCES.bootAnimation),
151
165
  conversationParticleDensity: enumOr(legacyDensities.conversation, PARTICLE_DENSITIES, particlePreset.conversationParticleDensity),
152
166
  heroParticleDensity: enumOr(legacyDensities.hero, PARTICLE_DENSITIES, particlePreset.heroParticleDensity),
167
+ particleTraversalSpeed: particleTraversalSpeedOr(
168
+ input.particleTraversalSpeed,
169
+ DEFAULT_PREFERENCES.particleTraversalSpeed,
170
+ ),
153
171
  particlePattern: 'orthogonal',
172
+ railDefaultHidden: booleanOr(input.railDefaultHidden, DEFAULT_PREFERENCES.railDefaultHidden),
154
173
  conversationScaleMaxDistance: scaleDistanceOr(
155
174
  input.conversationScaleMaxDistance,
156
175
  DEFAULT_PREFERENCES.conversationScaleMaxDistance,
@@ -205,7 +224,7 @@ export function loadPreferences(storage) {
205
224
  const raw = storage?.getItem(PRTS_STORAGE_KEY)
206
225
  if (raw === null || raw === undefined) return { ...DEFAULT_PREFERENCES }
207
226
  const parsed = JSON.parse(raw)
208
- if (![1, 2, 3, 4, 5, 6].includes(parsed?.version)) return { ...DEFAULT_PREFERENCES }
227
+ if (![1, 2, 3, 4, 5, 6, 7, 8].includes(parsed?.version)) return { ...DEFAULT_PREFERENCES }
209
228
  return normalizePreferences(parsed)
210
229
  } catch {
211
230
  return { ...DEFAULT_PREFERENCES }
@@ -29,6 +29,11 @@ export function createResizeShieldAdapter({
29
29
 
30
30
  function onResize() {
31
31
  if (!started) return
32
+ if (Number(window?.innerWidth) <= 640) {
33
+ clearTimer()
34
+ setActive(false)
35
+ return
36
+ }
32
37
  setActive(true)
33
38
  clearTimer()
34
39
  settleTimer = window?.setTimeout?.(settle, settleDelay)