@@ -1,17 +1,20 @@
< script setup >
import { ref , computed , onMounted , onUnmounted , watch } from 'vue'
import { ref , computed , onMounted , onUnmounted , watch , nextTick } from 'vue'
import { useCube } from '../../composables/useCube'
import { useSettings } from '../../composables/useSettings'
import { LAYER _ANIMATION _DURATION } from '../../config/animationSettings'
const { cubies , initCube , rotateLayer , turn , FACES } = useCube ( )
const { isCubeTranslucent } = useSettings ( )
// --- Visual State ---
const rx = ref ( - 25 ) // Initial View Rotation X
const ry = ref ( 45 ) // Initial View Rotation Y
const rx = ref ( - 25 )
const ry = ref ( 45 )
const rz = ref ( 0 )
const SCALE = 100 // Size of one cubie in px
const GAP = 0 // Gap between cubies
const SCALE = 100
const GAP = 0
const MIN _MOVES _COLUMN _GAP = 6
const movesColumnGap = ref ( MIN _MOVES _COLUMN _GAP )
// --- Interaction State ---
const isDragging = ref ( false )
@@ -29,6 +32,7 @@ const activeLayer = ref(null) // { axis, index, tangent, direction }
const currentLayerRotation = ref ( 0 ) // Visual rotation in degrees
const isAnimating = ref ( false )
const pendingLogicalUpdate = ref ( false )
const currentMoveId = ref ( null )
// --- Constants & Helpers ---
@@ -229,10 +233,9 @@ const snapRotation = () => {
const target = Math . round ( currentLayerRotation . value / 90 ) * 90
const steps = Math . round ( currentLayerRotation . value / 90 )
// Animation loop
const start = currentLayerRotation . value
const startTime = performance . now ( )
const duration = 200
const duration = LAYER _ANIMATION _DURATION
const animate = ( time ) => {
const p = Math . min ( ( time - startTime ) / duration , 1 )
@@ -264,6 +267,156 @@ const finishMove = (steps, directionOverride = null) => {
}
}
const movesHistory = ref ( [ ] )
const movesHistoryEl = ref ( null )
const samplePillEl = ref ( null )
const movesPerRow = ref ( 0 )
const isAddModalOpen = ref ( false )
const addMovesText = ref ( '' )
const displayMoves = computed ( ( ) => {
const list = movesHistory . value . slice ( )
moveQueue . forEach ( ( q , idx ) => {
const stepsMod = ( ( q . steps % 4 ) + 4 ) % 4
if ( stepsMod === 0 ) return
let modifier = ''
if ( stepsMod === 1 ) modifier = "'"
else if ( stepsMod === 2 ) modifier = '2'
else if ( stepsMod === 3 ) modifier = ''
const baseLabel = q . displayBase || q . base
const label = baseLabel + ( modifier === "'" ? "'" : modifier === '2' ? '2' : '' )
list . push ( {
id : ` q- ${ idx } ` ,
label ,
status : 'pending'
} )
} )
return list
} )
const moveRows = computed ( ( ) => {
const perRow = movesPerRow . value || displayMoves . value . length || 1
const rows = [ ]
const all = displayMoves . value
for ( let i = 0 ; i < all . length ; i += perRow ) {
rows . push ( all . slice ( i , i + perRow ) )
}
return rows
} )
const copyQueueToClipboard = async ( ) => {
if ( ! displayMoves . value . length ) return
const text = displayMoves . value . map ( m => m . label ) . join ( ' ' )
try {
if ( navigator && navigator . clipboard && navigator . clipboard . writeText ) {
await navigator . clipboard . writeText ( text )
} else {
const textarea = document . createElement ( 'textarea' )
textarea . value = text
textarea . style . position = 'fixed'
textarea . style . opacity = '0'
document . body . appendChild ( textarea )
textarea . focus ( )
textarea . select ( )
try {
document . execCommand ( 'copy' )
} finally {
document . body . removeChild ( textarea )
}
}
} catch ( e ) {
}
}
const setSamplePill = ( el ) => {
if ( el && ! samplePillEl . value ) {
samplePillEl . value = el
}
}
const recalcMovesLayout = ( ) => {
const container = movesHistoryEl . value
const pill = samplePillEl . value
if ( ! container || ! pill ) return
const containerWidth = container . clientWidth
const pillWidth = pill . offsetWidth
if ( pillWidth <= 0 ) return
const totalWidth = ( cols ) => {
if ( cols <= 0 ) return 0
if ( cols === 1 ) return pillWidth
return cols * pillWidth + ( cols - 1 ) * MIN _MOVES _COLUMN _GAP
}
let cols = Math . floor ( ( containerWidth + MIN _MOVES _COLUMN _GAP ) / ( pillWidth + MIN _MOVES _COLUMN _GAP ) )
if ( cols < 1 ) cols = 1
while ( cols > 1 && totalWidth ( cols ) > containerWidth ) {
cols -= 1
}
let gap = 0
if ( cols > 1 ) {
gap = ( containerWidth - cols * pillWidth ) / ( cols - 1 )
}
movesPerRow . value = cols
movesColumnGap . value = gap
}
const resetQueue = ( ) => {
moveQueue . length = 0
movesHistory . value = [ ]
currentMoveId . value = null
nextTick ( recalcMovesLayout )
}
const openAddModal = ( ) => {
addMovesText . value = ''
isAddModalOpen . value = true
}
const closeAddModal = ( ) => {
isAddModalOpen . value = false
}
const handleAddMoves = ( ) => {
const text = addMovesText . value || ''
const tokens = text . split ( /\s+/ ) . filter ( Boolean )
const moves = [ ]
tokens . forEach ( ( token ) => {
const t = token . trim ( )
if ( ! t ) return
const base = t [ 0 ]
if ( ! 'UDLRFB' . includes ( base ) ) return
const rest = t . slice ( 1 )
let key = null
if ( rest === '' ) key = base
else if ( rest === '2' ) key = base + '2'
else if ( rest === "'" || rest === '’ ' ) key = base + '-prime'
if ( key && MOVE _MAP [ key ] ) {
moves . push ( key )
}
} )
moves . forEach ( ( m ) => applyMove ( m ) )
addMovesText . value = ''
isAddModalOpen . value = false
}
const handleKeydown = ( e ) => {
if ( e . key === 'Escape' && isAddModalOpen . value ) {
e . preventDefault ( )
closeAddModal ( )
}
}
const getCubieStyle = ( c ) => {
// Base Position
const x = c . x * ( SCALE + GAP )
@@ -307,6 +460,38 @@ const getCubieStyle = (c) => {
const getProjectionStyle = ( ) => ( { } )
const moveQueue = [ ]
const dequeueMove = ( ) => {
while ( moveQueue . length ) {
const next = moveQueue . shift ( )
const stepsMod = ( ( next . steps % 4 ) + 4 ) % 4
if ( stepsMod === 0 ) continue
let modifier = ''
if ( stepsMod === 1 ) modifier = "'" // +90 (logical +1)
else if ( stepsMod === 2 ) modifier = '2' // 180 (logical -2)
else if ( stepsMod === 3 ) modifier = '' // -90 (logical -1)
return { base : next . base , modifier , displayBase : next . displayBase }
}
return null
}
const processNextMove = ( ) => {
if ( isAnimating . value || activeLayer . value ) return
const next = dequeueMove ( )
if ( ! next ) return
const baseLabel = next . displayBase || next . base
const label = baseLabel + ( next . modifier === "'" ? "'" : next . modifier === '2' ? '2' : '' )
const id = movesHistory . value . length
movesHistory . value . push ( { id , label , status : 'in_progress' } )
currentMoveId . value = id
animateProgrammaticMove ( next . base , next . modifier )
}
const animateProgrammaticMove = ( base , modifier ) => {
if ( isAnimating . value || activeLayer . value ) return
@@ -347,7 +532,7 @@ const animateProgrammaticMove = (base, modifier) => {
const target = visualSteps * 90
const start = 0
const startTime = performance . now ( )
const duration = 200 * count
const duration = LAYER _ANIMATION _DURATION * count
const animate = ( time ) => {
const p = Math . min ( ( time - startTime ) / duration , 1 )
@@ -396,29 +581,74 @@ const MOVE_MAP = {
const applyMove = ( move ) => {
const mapping = MOVE _MAP [ move ]
if ( ! mapping ) return
animateProgrammaticMove ( mapping . base , mapping . modifier )
let delta = 0
if ( mapping . modifier === "'" ) delta = 1 // logical +1
else if ( mapping . modifier === '' ) delta = - 1 // logical -1
else if ( mapping . modifier === '2' ) delta = - 2 // logical -2
const displayBase = move [ 0 ]
const last = moveQueue [ moveQueue . length - 1 ]
if ( last && last . base === mapping . base && last . displayBase === displayBase ) {
last . steps += delta
} else {
moveQueue . push ( { base : mapping . base , displayBase , steps : delta } )
}
processNextMove ( )
}
const allMoves = Object . keys ( MOVE _MAP )
const scramble = ( ) => {
for ( let i = 0 ; i < 30 ; i += 1 ) {
const move = allMoves [ Math . floor ( Math . random ( ) * allMoves . length ) ]
applyMove ( move )
}
}
watch ( cubies , ( ) => {
if ( ! pendingLogicalUpdate . value ) return
pendingLogicalUpdate . value = false
if ( currentMoveId . value !== null ) {
const idx = movesHistory . value . findIndex ( m => m . id === currentMoveId . value )
if ( idx !== - 1 ) {
movesHistory . value [ idx ] = {
... movesHistory . value [ idx ] ,
status : 'done'
}
}
currentMoveId . value = null
}
activeLayer . value = null
currentLayerRotation . value = 0
isAnimating . value = false
selectedCubie . value = null
selectedFace . value = null
processNextMove ( )
} )
onMounted ( ( ) => {
initCube ( )
window . addEventListener ( 'mousemove' , onMouseMove )
window . addEventListener ( 'mouseup' , onMouseUp )
window . addEventListener ( 'resize' , recalcMovesLayout )
window . addEventListener ( 'keydown' , handleKeydown )
nextTick ( recalcMovesLayout )
} )
onUnmounted ( ( ) => {
window . removeEventListener ( 'mousemove' , onMouseMove )
window . removeEventListener ( 'mouseup' , onMouseUp )
// Clean up any potential animation frames? rafId is local to snapRotation, but harmless.
window . removeEventListener ( 'resize' , recalcMovesLayout )
window . removeEventListener ( 'keydown' , handleKeydown )
} )
watch ( displayMoves , ( ) => {
nextTick ( recalcMovesLayout )
} )
< / script >
@@ -479,6 +709,71 @@ onUnmounted(() => {
< button class = "btn-neon move-btn" @click ="applyMove('B2')" > B2 < / button >
< / div >
< / div >
< button class = "btn-neon move-btn scramble-btn" @click ="scramble" >
Scramble
< / button >
< div class = "moves-history" >
< div class = "moves-inner" ref = "movesHistoryEl" >
< div
v-for = "(row, rowIndex) in moveRows"
:key = "rowIndex"
class = "moves-row"
: style = "{ columnGap: movesColumnGap + 'px' }"
>
< span
v-for = "(m, idx) in row"
:key = "m.id"
class = "move-pill"
: class = "{
'move-pill-active': m.status === 'in_progress',
'move-pill-pending': m.status === 'pending'
}"
: ref = "rowIndex === 0 && idx === 0 ? setSamplePill : null"
>
{ { m . label } }
< / span >
< / div >
< / div >
< div class = "moves-actions" >
< button class = "queue-action" @click ="openAddModal" > add < / button >
< button
v-if = "displayMoves.length"
class = "queue-action"
@click ="copyQueueToClipboard"
>
copy
< / button >
< button
v-if = "displayMoves.length"
class = "queue-action"
@click ="resetQueue"
>
reset
< / button >
< / div >
< / div >
< div
v-if = "isAddModalOpen"
class = "moves-modal-backdrop"
@click.self ="closeAddModal"
>
< div class = "moves-modal" >
< textarea
v-model = "addMovesText"
class = "moves-modal-textarea"
/ >
< div class = "moves-modal-actions" >
< button class = "btn-neon move-btn moves-modal-button" @click ="closeAddModal" >
cancel
< / button >
< button class = "btn-neon move-btn moves-modal-button" @click ="handleAddMoves" >
add moves
< / button >
< / div >
< / div >
< / div >
< / div >
< / template >
@@ -545,6 +840,142 @@ onUnmounted(() => {
padding : 0 10 px ;
}
. scramble - btn {
position : absolute ;
bottom : 72 px ;
left : 24 px ;
z - index : 50 ;
}
. moves - history {
position : absolute ;
bottom : 72 px ;
left : 50 % ;
transform : translateX ( - 50 % ) ;
width : 100 % ;
max - width : calc ( 100 vw - 360 px ) ;
overflow - x : hidden ;
padding : 12 px 12 px 26 px 12 px ;
background : rgba ( 0 , 0 , 0 , 0.4 ) ;
border - radius : 8 px ;
backdrop - filter : blur ( 8 px ) ;
}
. moves - inner {
display : flex ;
flex - direction : column ;
gap : 6 px ;
}
. moves - row {
display : flex ;
}
. move - pill {
display : flex ;
align - items : center ;
justify - content : center ;
width : 16 px ;
padding : 4 px 8 px ;
border - radius : 999 px ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.2 ) ;
font - size : 0.8 rem ;
color : # fff ;
white - space : nowrap ;
}
. move - pill - active {
background : # ffd500 ;
color : # 000 ;
border - color : # ffd500 ;
}
. move - pill - pending {
opacity : 0.4 ;
}
. moves - actions {
position : absolute ;
right : 6 px ;
bottom : 6 px ;
display : flex ;
gap : 0 px ;
}
. queue - action {
border : none ;
background : transparent ;
padding : 6 px 6 px ;
color : # fff ;
font - size : 0.8 rem ;
cursor : pointer ;
}
. moves - history : : after {
content : none ;
}
. queue - action : focus {
outline : none ;
box - shadow : none ;
}
. moves - modal - backdrop {
position : fixed ;
inset : 0 ;
background : rgba ( 0 , 0 , 0 , 0.65 ) ;
display : flex ;
align - items : center ;
justify - content : center ;
z - index : 200 ;
}
. moves - modal {
background : var ( -- panel - bg ) ;
border : 1 px solid var ( -- panel - border ) ;
color : var ( -- text - color ) ;
border - radius : 10 px ;
padding : 24 px ;
min - width : 480 px ;
max - width : 800 px ;
box - shadow : 0 20 px 40 px rgba ( 0 , 0 , 0 , 0.7 ) ;
}
. moves - modal - textarea {
width : 100 % ;
min - height : 220 px ;
background : var ( -- panel - bg ) ;
color : var ( -- text - color ) ;
box - sizing : border - box ;
border - radius : 6 px ;
border : 1 px solid var ( -- panel - border ) ;
padding : 10 px ;
resize : vertical ;
font - family : inherit ;
font - size : 0.85 rem ;
}
. moves - modal - textarea : focus {
outline : none ;
box - shadow : none ;
}
. moves - modal - actions {
margin - top : 20 px ;
display : flex ;
justify - content : flex - end ;
gap : 12 px ;
}
. moves - modal - button {
font - size : 0.85 rem ;
}
. moves - modal - button : focus {
outline : none ;
box - shadow : none ;
}
/* Projection Styles */
. projections {
position : absolute ;