Major new version (#2)

Release Notes

Architecture
- Component refactor: removed monoliths (`Calendar.vue`, `CalendarGrid.vue`); expanded granular view + header/control components.
- Dialog system introduced (`BaseDialog`, `SettingsDialog`).

State & Data
- Store redesigned: Map-based events + recurrence map; mutation counters.
- Local persistence + undo/redo history (custom plugins).

Date & Holidays
- Migrated all date logic to `date-fns` (+ tz).
- Added national holiday support (toggle + loading utilities).

Recurrence & Events
- Consolidated recurrence handling; fixes for monthly edge days (29–31), annual, multi‑day, and complex weekly repeats.
- Reliable splitting/moving/resizing/deletion of repeating and multi‑day events.

Interaction & UX
- Double‑tap to create events; improved drag (multi‑day + position retention).
- Scroll & inertial/momentum navigation; year change via numeric scroller.
- Movable event dialog; live settings application.

Performance
- Progressive / virtual week rendering, reduced off‑screen buffer.
- Targeted repaint strategy; minimized full re-renders.

Plugins Added
- History, undo normalization, persistence, scroll manager, virtual weeks.

Styling & Layout
- Responsive + compact layout refinements; header restructured.
- Simplified visual elements (removed dots/overflow text); holiday styling adjustments.

Reliability / Fixes
- Numerous recurrence, deletion, orientation/rotation, and event indexing corrections.
- Cross-browser fallback (Firefox week info).

Dependencies Added
- date-fns, date-fns-tz, date-holidays, pinia-plugin-persistedstate.

Net Change
- 28 files modified; ~4.4K insertions / ~2.2K deletions (major refactor + feature set).
This commit is contained in:
2025-08-26 03:58:24 +00:00
parent 86a06acccb
commit dbe4c99b07
28 changed files with 4465 additions and 2207 deletions
+216 -364
View File
@@ -5,6 +5,8 @@
:key="span.id"
class="event-span"
:class="[`event-color-${span.colorId}`]"
:data-id="span.id"
:data-n="span._recurrenceIndex != null ? span._recurrenceIndex : 0"
:style="{
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`,
gridRow: `${span.row}`,
@@ -24,174 +26,104 @@
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/utils/date'
import { daysInclusive, addDaysStr } from '@/utils/date'
const props = defineProps({
week: {
type: Object,
required: true,
},
week: { type: Object, required: true },
})
const emit = defineEmits(['event-click'])
const store = useCalendarStore()
// Local drag state
// Drag state
const dragState = ref(null)
const justDragged = ref(false)
// Generate repeat occurrences for a specific date
function generateRepeatOccurrencesForDate(targetDateStr) {
const occurrences = []
// Get all events from the store and check for repeating ones
for (const [, eventList] of store.events) {
for (const baseEvent of eventList) {
if (!baseEvent.isRepeating || baseEvent.repeat === 'none') {
continue
}
const targetDate = new Date(fromLocalString(targetDateStr))
const baseStartDate = new Date(fromLocalString(baseEvent.startDate))
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
if (baseEvent.repeat === 'weeks') {
const repeatWeekdays = baseEvent.repeatWeekdays
if (targetDate < baseStartDate) continue
const maxOccurrences =
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
if (maxOccurrences === 0) continue
const interval = baseEvent.repeatInterval || 1
const msPerDay = 24 * 60 * 60 * 1000
// Determine if targetDate lies within some occurrence span. We look backwards up to spanDays to find a start day.
let occStart = null
for (let back = 0; back <= spanDays; back++) {
const cand = new Date(targetDate)
cand.setDate(cand.getDate() - back)
if (cand < baseStartDate) break
const daysDiff = Math.floor((cand - baseStartDate) / msPerDay)
const weeksDiff = Math.floor(daysDiff / 7)
if (weeksDiff % interval !== 0) continue
if (repeatWeekdays[cand.getDay()]) {
// candidate start must produce span covering targetDate
const candEnd = new Date(cand)
candEnd.setDate(candEnd.getDate() + spanDays)
if (targetDate <= candEnd) {
occStart = cand
break
}
}
}
if (!occStart) continue
// Skip base occurrence if this is within its span (base already physically stored)
if (occStart.getTime() === baseStartDate.getTime()) continue
// Compute occurrence index (number of previous start days)
let occIdx = 0
const cursor = new Date(baseStartDate)
while (cursor < occStart && occIdx < maxOccurrences) {
const cDaysDiff = Math.floor((cursor - baseStartDate) / msPerDay)
const cWeeksDiff = Math.floor(cDaysDiff / 7)
if (cWeeksDiff % interval === 0 && repeatWeekdays[cursor.getDay()]) occIdx++
cursor.setDate(cursor.getDate() + 1)
}
if (occIdx >= maxOccurrences) continue
const occEnd = new Date(occStart)
occEnd.setDate(occStart.getDate() + spanDays)
const occStartStr = toLocalString(occStart)
const occEndStr = toLocalString(occEnd)
occurrences.push({
...baseEvent,
id: `${baseEvent.id}_repeat_${occIdx}_${occStart.getDay()}`,
startDate: occStartStr,
endDate: occEndStr,
isRepeatOccurrence: true,
repeatIndex: occIdx,
})
continue
// Consolidate already-provided day.events into contiguous spans (no recurrence generation)
const eventSpans = computed(() => {
const weekEvents = new Map()
props.week.days.forEach((day, dayIndex) => {
day.events.forEach((ev) => {
const key = ev.id
if (!weekEvents.has(key)) {
weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex })
} else {
// Handle other repeat types (months)
let intervalsPassed = 0
const timeDiff = targetDate - baseStartDate
if (baseEvent.repeat === 'months') {
intervalsPassed =
(targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
(targetDate.getMonth() - baseStartDate.getMonth())
} else {
continue
}
const interval = baseEvent.repeatInterval || 1
if (intervalsPassed < 0 || intervalsPassed % interval !== 0) continue
// Check a few occurrences around the target date
const maxOccurrences =
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
if (maxOccurrences === 0) continue
const i = intervalsPassed
if (i >= maxOccurrences) continue
const currentStart = new Date(baseStartDate)
currentStart.setMonth(baseStartDate.getMonth() + i)
const currentEnd = new Date(currentStart)
currentEnd.setDate(currentStart.getDate() + spanDays)
// If target day lies within base (i===0) we skip because base is stored already
if (i === 0) {
// only skip if targetDate within base span
if (targetDate >= baseStartDate && targetDate <= baseEndDate) continue
}
const currentStartStr = toLocalString(currentStart)
const currentEndStr = toLocalString(currentEnd)
if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) {
occurrences.push({
...baseEvent,
id: `${baseEvent.id}_repeat_${i}`,
startDate: currentStartStr,
endDate: currentEndStr,
isRepeatOccurrence: true,
repeatIndex: i,
})
}
const ref = weekEvents.get(key)
ref.endIdx = Math.max(ref.endIdx, dayIndex)
}
})
})
const arr = Array.from(weekEvents.values())
arr.sort((a, b) => {
const spanA = a.endIdx - a.startIdx
const spanB = b.endIdx - b.startIdx
if (spanA !== spanB) return spanB - spanA
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
// For one-day events that are otherwise equal, sort by color (0 first)
if (spanA === 0 && spanB === 0 && a.startIdx === b.startIdx) {
const colorA = a.colorId || 0
const colorB = b.colorId || 0
if (colorA !== colorB) return colorA - colorB
}
}
return String(a.id).localeCompare(String(b.id))
})
// Assign non-overlapping rows
const rowsLastEnd = []
arr.forEach((ev) => {
let row = 0
while (row < rowsLastEnd.length && !(ev.startIdx > rowsLastEnd[row])) row++
if (row === rowsLastEnd.length) rowsLastEnd.push(-1)
rowsLastEnd[row] = ev.endIdx
ev.row = row + 1
})
return arr
})
return occurrences
}
// Extract original event ID from repeat occurrence ID
function getOriginalEventId(eventId) {
if (typeof eventId === 'string' && eventId.includes('_repeat_')) {
return eventId.split('_repeat_')[0]
}
return eventId
}
// Handle event click
function handleEventClick(span) {
if (justDragged.value) return
// Emit the actual span id (may include repeat suffix) so edit dialog knows occurrence context
emit('event-click', span.id)
// Emit composite payload: base id (without virtual marker), instance id, occurrence index (data-n)
const idStr = span.id
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
emit('event-click', {
id: baseId,
instanceId: span.id,
occurrenceIndex: span._recurrenceIndex != null ? span._recurrenceIndex : 0,
})
}
// Handle event pointer down for dragging
function handleEventPointerDown(span, event) {
// Don't start drag if clicking on resize handle
if (event.target.classList.contains('resize-handle')) return
event.stopPropagation()
// Do not preventDefault here to allow click unless drag threshold is passed
// Get the date under the pointer
const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget)
const anchorDate = hit ? hit.date : span.startDate
const idStr = span.id
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
const isVirtual = hasVirtualMarker
// Determine which day within the span was grabbed so we maintain relative position
let anchorDate = span.startDate
try {
const spanDays = daysInclusive(span.startDate, span.endDate)
const targetEl = event.currentTarget
if (targetEl && spanDays > 0) {
const rect = targetEl.getBoundingClientRect()
const relX = event.clientX - rect.left
const dayWidth = rect.width / spanDays
let dayIndex = Math.floor(relX / dayWidth)
if (!isFinite(dayIndex)) dayIndex = 0
if (dayIndex < 0) dayIndex = 0
if (dayIndex >= spanDays) dayIndex = spanDays - 1
anchorDate = addDaysStr(span.startDate, dayIndex)
}
} catch (e) {
// Fallback to startDate if any calculation fails
}
startLocalDrag(
{
id: span.id,
id: baseId,
originalId: span.id,
isVirtual,
mode: 'move',
pointerStartX: event.clientX,
pointerStartY: event.clientY,
@@ -203,13 +135,17 @@ function handleEventPointerDown(span, event) {
)
}
// Handle resize handle pointer down
function handleResizePointerDown(span, mode, event) {
event.stopPropagation()
// Start drag from the current edge; anchorDate not needed for resize
const idStr = span.id
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
const isVirtual = hasVirtualMarker
startLocalDrag(
{
id: span.id,
id: baseId,
originalId: span.id,
isVirtual,
mode,
pointerStartX: event.clientX,
pointerStartY: event.clientY,
@@ -221,94 +157,6 @@ function handleResizePointerDown(span, mode, event) {
)
}
// Get date under pointer coordinates
function getDateUnderPointer(clientX, clientY, targetEl) {
// First try to find a day cell directly under the pointer
let element = document.elementFromPoint(clientX, clientY)
// If we hit an event element, temporarily hide it and try again
const hiddenElements = []
while (element && element.classList.contains('event-span')) {
element.style.pointerEvents = 'none'
hiddenElements.push(element)
element = document.elementFromPoint(clientX, clientY)
}
// Restore pointer events for hidden elements
hiddenElements.forEach((el) => (el.style.pointerEvents = 'auto'))
if (element) {
// Look for a day cell with data-date attribute
const dayElement = element.closest('[data-date]')
if (dayElement && dayElement.dataset.date) {
return { date: dayElement.dataset.date }
}
// Also check if we're over a week element and can calculate position
const weekElement = element.closest('.week-row')
if (weekElement) {
const rect = weekElement.getBoundingClientRect()
const relativeX = clientX - rect.left
const dayWidth = rect.width / 7
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
const daysGrid = weekElement.querySelector('.days-grid')
if (daysGrid && daysGrid.children[dayIndex]) {
const dayEl = daysGrid.children[dayIndex]
const date = dayEl?.dataset?.date
if (date) return { date }
}
}
}
// Fallback: try to find the week overlay and calculate position
const overlayEl = targetEl?.closest('.week-overlay')
const weekElement = overlayEl ? overlayEl.parentElement : null
if (!weekElement) {
// If we're outside this week, try to find any week element under the pointer
const allWeekElements = document.querySelectorAll('.week-row')
let bestWeek = null
let bestDistance = Infinity
for (const week of allWeekElements) {
const rect = week.getBoundingClientRect()
if (clientY >= rect.top && clientY <= rect.bottom) {
const distance = Math.abs(clientY - (rect.top + rect.height / 2))
if (distance < bestDistance) {
bestDistance = distance
bestWeek = week
}
}
}
if (bestWeek) {
const rect = bestWeek.getBoundingClientRect()
const relativeX = clientX - rect.left
const dayWidth = rect.width / 7
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
const daysGrid = bestWeek.querySelector('.days-grid')
if (daysGrid && daysGrid.children[dayIndex]) {
const dayEl = daysGrid.children[dayIndex]
const date = dayEl?.dataset?.date
if (date) return { date }
}
}
return null
}
const rect = weekElement.getBoundingClientRect()
const relativeX = clientX - rect.left
const dayWidth = rect.width / 7
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
if (props.week.days[dayIndex]) {
return { date: props.week.days[dayIndex].date }
}
return null
}
// Local drag handling
function startLocalDrag(init, evt) {
const spanDays = daysInclusive(init.startDate, init.endDate)
@@ -319,13 +167,39 @@ function startLocalDrag(init, evt) {
else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1
}
// Capture original repeating pattern & weekday (for weekly repeats) so we can rotate relative to original
let originalWeekday = null
let originalPattern = null
if (init.mode === 'move') {
try {
originalWeekday = new Date(init.startDate + 'T00:00:00').getDay()
const baseEv = store.getEventById(init.id)
if (
baseEv &&
baseEv.recur &&
baseEv.recur.freq === 'weeks' &&
Array.isArray(baseEv.recur.weekdays)
) {
originalPattern = [...baseEv.recur.weekdays]
}
} catch {}
}
dragState.value = {
...init,
anchorOffset,
originSpanDays: spanDays,
eventMoved: false,
tentativeStart: init.startDate,
tentativeEnd: init.endDate,
originalWeekday,
originalPattern,
realizedId: null, // for virtual occurrence converted to real during drag
}
// Begin compound history session (single snapshot after drag completes)
store.$history?.beginCompound()
// Capture pointer events globally
if (evt.currentTarget && evt.pointerId !== undefined) {
try {
@@ -335,14 +209,35 @@ function startLocalDrag(init, evt) {
}
}
// Prevent default to avoid text selection and other interference
evt.preventDefault()
// Prevent default for mouse/pen to avoid text selection. For touch we skip so the user can still scroll.
if (!(evt.pointerType === 'touch')) {
evt.preventDefault()
}
window.addEventListener('pointermove', onDragPointerMove, { passive: false })
window.addEventListener('pointerup', onDragPointerUp, { passive: false })
window.addEventListener('pointercancel', onDragPointerUp, { passive: false })
}
// Determine date under pointer: traverse DOM to find day cell carrying data-date attribute
function getDateUnderPointer(x, y, el) {
let cur = el
while (cur) {
if (cur.dataset && cur.dataset.date) {
return { date: cur.dataset.date }
}
cur = cur.parentElement
}
// Fallback: elementFromPoint scan
const probe = document.elementFromPoint(x, y)
let p = probe
while (p) {
if (p.dataset && p.dataset.date) return { date: p.dataset.date }
p = p.parentElement
}
return null
}
function onDragPointerMove(e) {
const st = dragState.value
if (!st) return
@@ -360,7 +255,66 @@ function onDragPointerMove(e) {
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
if (!ns || !ne) return
applyRangeDuringDrag(st, ns, ne)
// Only proceed if changed
if (ns === st.tentativeStart && ne === st.tentativeEnd) return
st.tentativeStart = ns
st.tentativeEnd = ne
if (st.mode === 'move') {
if (st.isVirtual) {
// On first movement convert virtual occurrence into a real new event (split series)
if (!st.realizedId) {
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne)
if (newId) {
st.realizedId = newId
st.id = newId
st.isVirtual = false
} else {
return
}
} else {
// Subsequent moves: update range without rotating pattern automatically
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
}
} else {
// Normal non-virtual move; rotate handled in setEventRange
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
}
// Manual rotation relative to original pattern (keeps pattern anchored to initially grabbed weekday)
if (st.originalPattern && st.originalWeekday != null) {
try {
const currentWeekday = new Date(ns + 'T00:00:00').getDay()
const shift = currentWeekday - st.originalWeekday
const rotated = store._rotateWeekdayPattern([...st.originalPattern], shift)
const ev = store.getEventById(st.id)
if (ev && ev.recur && ev.recur.freq === 'weeks') {
ev.recur.weekdays = rotated
store.touchEvents()
}
} catch {}
}
} else if (!st.isVirtual) {
// Resizes on real events update immediately
applyRangeDuringDrag(
{ id: st.id, isVirtual: st.isVirtual, mode: st.mode, startDate: ns, endDate: ne },
ns,
ne,
)
} else if (st.isVirtual && (st.mode === 'resize-left' || st.mode === 'resize-right')) {
// For virtual occurrence resize: convert to real once, then adjust range
if (!st.realizedId) {
const initialStart = ns
const initialEnd = ne
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, initialStart, initialEnd)
if (newId) {
st.realizedId = newId
st.id = newId
st.isVirtual = false
} else return
}
// Apply range change; rotate if left edge moved and weekday changed
const rotate = st.mode === 'resize-left'
store.setEventRange(st.id, ns, ne, { mode: st.mode, rotatePattern: rotate })
}
}
function onDragPointerUp(e) {
@@ -377,6 +331,8 @@ function onDragPointerUp(e) {
}
const moved = !!st.eventMoved
const finalStart = st.tentativeStart
const finalEnd = st.tentativeEnd
dragState.value = null
window.removeEventListener('pointermove', onDragPointerMove)
@@ -384,11 +340,27 @@ function onDragPointerUp(e) {
window.removeEventListener('pointercancel', onDragPointerUp)
if (moved) {
// Apply final mutation if virtual (we deferred) or if non-virtual no further change (rare)
if (st.isVirtual) {
applyRangeDuringDrag(
{
id: st.id,
isVirtual: st.isVirtual,
mode: st.mode,
startDate: finalStart,
endDate: finalEnd,
},
finalStart,
finalEnd,
)
}
justDragged.value = true
setTimeout(() => {
justDragged.value = false
}, 120)
}
// End compound session (snapshot if changed)
store.$history?.endCompound()
}
function computeTentativeRangeFromPointer(st, dropDateStr) {
@@ -416,133 +388,13 @@ function normalizeDateOrder(aStr, bStr) {
}
function applyRangeDuringDrag(st, startDate, endDate) {
let ev = store.getEventById(st.id)
let isRepeatOccurrence = false
let baseId = st.id
let repeatIndex = 0
let grabbedWeekday = null
// If not found (repeat occurrences aren't stored) parse synthetic id
if (!ev && typeof st.id === 'string' && st.id.includes('_repeat_')) {
const [bid, suffix] = st.id.split('_repeat_')
baseId = bid
ev = store.getEventById(baseId)
if (ev) {
const parts = suffix.split('_')
repeatIndex = parseInt(parts[0], 10) || 0
grabbedWeekday = parts.length > 1 ? parseInt(parts[1], 10) : null
isRepeatOccurrence = repeatIndex >= 0
}
if (st.isVirtual) {
if (st.mode !== 'move') return // no resize for virtual occurrence
// Split-move: occurrence being dragged treated as first of new series
store.splitMoveVirtualOccurrence(st.id, st.startDate, startDate, endDate)
return
}
if (!ev) return
const mode = st.mode === 'resize-left' || st.mode === 'resize-right' ? st.mode : 'move'
if (isRepeatOccurrence) {
if (repeatIndex === 0) {
store.setEventRange(baseId, startDate, endDate, { mode })
} else {
if (!st.splitNewBaseId) {
const newId = store.splitRepeatSeries(
baseId,
repeatIndex,
startDate,
endDate,
grabbedWeekday,
)
if (newId) {
st.splitNewBaseId = newId
st.id = newId
st.startDate = startDate
st.endDate = endDate
}
} else {
store.setEventRange(st.splitNewBaseId, startDate, endDate, { mode })
}
}
} else {
store.setEventRange(st.id, startDate, endDate, { mode })
}
}
// Calculate event spans for this week
const eventSpans = computed(() => {
const spans = []
const weekEvents = new Map()
// Collect events from all days in this week, including repeat occurrences
props.week.days.forEach((day, dayIndex) => {
// Get base events for this day
day.events.forEach((event) => {
if (!weekEvents.has(event.id)) {
weekEvents.set(event.id, {
...event,
startIdx: dayIndex,
endIdx: dayIndex,
})
} else {
const existing = weekEvents.get(event.id)
existing.endIdx = dayIndex
}
})
// Generate repeat occurrences for this day
const repeatOccurrences = generateRepeatOccurrencesForDate(day.date)
repeatOccurrences.forEach((event) => {
if (!weekEvents.has(event.id)) {
weekEvents.set(event.id, {
...event,
startIdx: dayIndex,
endIdx: dayIndex,
})
} else {
const existing = weekEvents.get(event.id)
existing.endIdx = dayIndex
}
})
})
// Convert to array and sort
const eventArray = Array.from(weekEvents.values())
eventArray.sort((a, b) => {
// Sort by span length (longer first)
const spanA = a.endIdx - a.startIdx
const spanB = b.endIdx - b.startIdx
if (spanA !== spanB) return spanB - spanA
// Then by start position
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
// Then by start time if available
const timeA = a.startTime ? timeToMinutes(a.startTime) : 0
const timeB = b.startTime ? timeToMinutes(b.startTime) : 0
if (timeA !== timeB) return timeA - timeB
// Fallback to ID
return String(a.id).localeCompare(String(b.id))
})
// Assign rows to avoid overlaps
const rowsLastEnd = []
eventArray.forEach((event) => {
let placedRow = 0
while (placedRow < rowsLastEnd.length && !(event.startIdx > rowsLastEnd[placedRow])) {
placedRow++
}
if (placedRow === rowsLastEnd.length) {
rowsLastEnd.push(-1)
}
rowsLastEnd[placedRow] = event.endIdx
event.row = placedRow + 1
})
return eventArray
})
function timeToMinutes(timeStr) {
if (!timeStr) return 0
const [hours, minutes] = timeStr.split(':').map(Number)
return hours * 60 + minutes
store.setEventRange(st.id, startDate, endDate, { mode: st.mode })
}
</script>
@@ -564,7 +416,7 @@ function timeToMinutes(timeStr) {
.event-span {
padding: 0.1em 0.3em;
border-radius: 0.2em;
border-radius: 1em;
font-size: clamp(0.45em, 1.8vh, 0.75em);
font-weight: 600;
cursor: grab;