Merge pull request 'feat(schedule): right-click menu + drag-to-resize on EPG event blocks' (#25) from feat/epg-resize-and-ctxmenu into main
This commit is contained in:
commit
0537378d82
2 changed files with 261 additions and 31 deletions
|
|
@ -1018,55 +1018,158 @@ function _EpgRuler({ pph }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick }) {
|
// Minimum schedule length the UI permits while dragging. Anything shorter
|
||||||
const s = new Date(event.start_at);
|
// rarely reflects a real plan and would let the operator accidentally
|
||||||
const e = new Date(event.end_at);
|
// dismiss a block to zero width.
|
||||||
const startMin = _minutesIntoDay(s, dayStart);
|
const _EPG_MIN_MS = 5 * 60 * 1000;
|
||||||
const endMin = _minutesIntoDay(e, dayStart);
|
// Drag snap quantum. Mirrors the new-schedule click snap so a resized
|
||||||
|
// block lines up to the same grid an operator just placed a new one on.
|
||||||
|
const _EPG_SNAP_MIN = 15;
|
||||||
|
// Pointer travel (in px) before we treat the gesture as a drag rather
|
||||||
|
// than a click. Below this, pointerup fires the click handler.
|
||||||
|
const _EPG_DRAG_THRESHOLD = 4;
|
||||||
|
|
||||||
|
function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick, onContextMenu, onResize }) {
|
||||||
|
// Drag state: null when idle, otherwise the in-flight resize/move
|
||||||
|
// describing the original times and the current (snapped) preview times.
|
||||||
|
// We render from this preview while dragging so the block follows the
|
||||||
|
// cursor without round-tripping through props.
|
||||||
|
const [drag, setDrag] = React.useState(null);
|
||||||
|
const blockRef = React.useRef(null);
|
||||||
|
|
||||||
|
const eventStartMs = new Date(event.start_at).getTime();
|
||||||
|
const eventEndMs = new Date(event.end_at).getTime();
|
||||||
|
const isLive = event.status === 'running' || (event.status === 'pending' && eventStartMs <= now.getTime() && now.getTime() < eventEndMs);
|
||||||
|
const isFailed = event.status === 'failed';
|
||||||
|
const isPast = (event.status === 'completed' || event.status === 'cancelled') || eventEndMs < now.getTime();
|
||||||
|
const color = _projectColor(recorder?.project_id, projects);
|
||||||
|
|
||||||
|
// Only pending schedules can be resized. The API rejects PUTs against
|
||||||
|
// running schedules outright; cancelling them is what an operator
|
||||||
|
// actually wants there. Terminal statuses are read-only.
|
||||||
|
const canDrag = event.status === 'pending';
|
||||||
|
|
||||||
|
const startDrag = (e, type) => {
|
||||||
|
if (!canDrag) return;
|
||||||
|
if (e.button !== 0) return; // ignore right-click
|
||||||
|
e.stopPropagation();
|
||||||
|
try { blockRef.current.setPointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
setDrag({
|
||||||
|
type, pointerId: e.pointerId,
|
||||||
|
startX: e.clientX,
|
||||||
|
origStart: eventStartMs, origEnd: eventEndMs,
|
||||||
|
currStart: eventStartMs, currEnd: eventEndMs,
|
||||||
|
moved: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (ev) => {
|
||||||
|
if (!drag) return;
|
||||||
|
const dx = ev.clientX - drag.startX;
|
||||||
|
if (!drag.moved && Math.abs(dx) < _EPG_DRAG_THRESHOLD) return;
|
||||||
|
const snapMs = _EPG_SNAP_MIN * 60 * 1000;
|
||||||
|
const dMs = Math.round((dx / pph) * 3600 * 1000 / snapMs) * snapMs;
|
||||||
|
const dayStartMs = dayStart.getTime();
|
||||||
|
const dayEndMs = dayEnd.getTime();
|
||||||
|
let cs = drag.origStart, ce = drag.origEnd;
|
||||||
|
if (drag.type === 'left') {
|
||||||
|
cs = Math.max(dayStartMs, Math.min(drag.origEnd - _EPG_MIN_MS, drag.origStart + dMs));
|
||||||
|
} else if (drag.type === 'right') {
|
||||||
|
ce = Math.min(dayEndMs, Math.max(drag.origStart + _EPG_MIN_MS, drag.origEnd + dMs));
|
||||||
|
} else if (drag.type === 'body') {
|
||||||
|
cs = drag.origStart + dMs;
|
||||||
|
ce = drag.origEnd + dMs;
|
||||||
|
// Clamp to the day; preserve duration when bumping against an edge.
|
||||||
|
if (cs < dayStartMs) { ce += (dayStartMs - cs); cs = dayStartMs; }
|
||||||
|
if (ce > dayEndMs) { cs -= (ce - dayEndMs); ce = dayEndMs; }
|
||||||
|
}
|
||||||
|
setDrag({ ...drag, currStart: cs, currEnd: ce, moved: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = (ev) => {
|
||||||
|
if (!drag) return;
|
||||||
|
try { blockRef.current.releasePointerCapture(drag.pointerId); } catch (_) {}
|
||||||
|
const d = drag;
|
||||||
|
setDrag(null);
|
||||||
|
if (!d.moved) {
|
||||||
|
// Treat as a click — open the edit modal.
|
||||||
|
onClick(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d.currStart === d.origStart && d.currEnd === d.origEnd) return;
|
||||||
|
onResize(event, new Date(d.currStart).toISOString(), new Date(d.currEnd).toISOString());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render from drag preview while a gesture is in flight so the block
|
||||||
|
// tracks the pointer; otherwise from the canonical event prop.
|
||||||
|
const dispStartMs = drag ? drag.currStart : eventStartMs;
|
||||||
|
const dispEndMs = drag ? drag.currEnd : eventEndMs;
|
||||||
|
const dispStart = new Date(dispStartMs);
|
||||||
|
const dispEnd = new Date(dispEndMs);
|
||||||
|
const startMin = _minutesIntoDay(dispStart, dayStart);
|
||||||
|
const endMin = _minutesIntoDay(dispEnd, dayStart);
|
||||||
const left = (startMin / 60) * pph;
|
const left = (startMin / 60) * pph;
|
||||||
const width = Math.max(40, ((endMin - startMin) / 60) * pph);
|
const width = Math.max(40, ((endMin - startMin) / 60) * pph);
|
||||||
|
|
||||||
const isLive = event.status === 'running' || (event.status === 'pending' && s <= now && now < e);
|
|
||||||
const isFailed = event.status === 'failed';
|
|
||||||
const isPast = (event.status === 'completed' || event.status === 'cancelled') || e < now;
|
|
||||||
const color = _projectColor(recorder?.project_id, projects);
|
|
||||||
|
|
||||||
const classes = ['epg-block'];
|
const classes = ['epg-block'];
|
||||||
if (isLive) classes.push('live');
|
if (isLive) classes.push('live');
|
||||||
if (isFailed) classes.push('failed');
|
if (isFailed) classes.push('failed');
|
||||||
else if (isPast) classes.push('past');
|
else if (isPast) classes.push('past');
|
||||||
|
if (drag && drag.moved) classes.push('dragging');
|
||||||
|
if (canDrag) classes.push('resizable');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
|
ref={blockRef}
|
||||||
className={classes.join(' ')}
|
className={classes.join(' ')}
|
||||||
style={{ left, width, '--epg-block-color': color || 'var(--text-3)' }}
|
style={{ left, width, '--epg-block-color': color || 'var(--text-3)' }}
|
||||||
onClick={(ev) => { ev.stopPropagation(); onClick(event); }}
|
onContextMenu={(ev) => { ev.preventDefault(); ev.stopPropagation(); onContextMenu(event, ev); }}
|
||||||
title={event.name + ' · ' + _fmtTime(s) + ' → ' + _fmtTime(e) + (event.error_message ? ' · ' + event.error_message : '')}>
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
title={event.name + ' · ' + _fmtTime(dispStart) + ' → ' + _fmtTime(dispEnd) + (event.error_message ? ' · ' + event.error_message : '')}>
|
||||||
<span className="epg-block-bar" />
|
<span className="epg-block-bar" />
|
||||||
<span className="epg-block-name">{event.name}</span>
|
{/* Body click → edit, body drag → move. We hang the click on pointerup
|
||||||
<span className="epg-block-time mono">{_fmtTime(s)}</span>
|
so the threshold check above can demote a drag back to a click. */}
|
||||||
{isLive && <span className="epg-block-glyph live" title="on air">●</span>}
|
<div className="epg-block-body" onPointerDown={(ev) => startDrag(ev, 'body')}>
|
||||||
{isFailed && <span className="epg-block-glyph failed" title={event.error_message || 'failed'}>!</span>}
|
<span className="epg-block-name">{event.name}</span>
|
||||||
</button>
|
<span className="epg-block-time mono">{_fmtTime(dispStart)}{drag && drag.moved ? ' → ' + _fmtTime(dispEnd) : ''}</span>
|
||||||
|
{isLive && <span className="epg-block-glyph live" title="on air">●</span>}
|
||||||
|
{isFailed && <span className="epg-block-glyph failed" title={event.error_message || 'failed'}>!</span>}
|
||||||
|
</div>
|
||||||
|
{canDrag && (
|
||||||
|
<>
|
||||||
|
<span className="epg-block-handle left"
|
||||||
|
onPointerDown={(ev) => startDrag(ev, 'left')}
|
||||||
|
title="Drag to change start time" />
|
||||||
|
<span className="epg-block-handle right"
|
||||||
|
onPointerDown={(ev) => startDrag(ev, 'right')}
|
||||||
|
title="Drag to change end time" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, onEventClick, onEmptyClick }) {
|
function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, onEventClick, onEmptyClick, onEventContextMenu, onEventResize }) {
|
||||||
const dayEvents = (schedules || []).filter(s => s.recorder_id === recorder.id && _eventOverlapsDay(s, dayStart, dayEnd));
|
const dayEvents = (schedules || []).filter(s => s.recorder_id === recorder.id && _eventOverlapsDay(s, dayStart, dayEnd));
|
||||||
|
|
||||||
const handleRowClick = (e) => {
|
const handleRowPointerUp = (e) => {
|
||||||
// Translate clicked x to a Date in this row's day. Snap to 15-minute
|
// Open the new-schedule modal only on a real click in the empty
|
||||||
// increments so the resulting modal pre-fill looks intentional.
|
// gutter. Clicks on event blocks stopPropagation themselves; we also
|
||||||
|
// guard against the tail of a block-drag bubbling up here.
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (e.button !== 0) return;
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
const minutes = Math.max(0, Math.min(24 * 60 - 30, Math.round((x / pph) * 60 / 15) * 15));
|
const minutes = Math.max(0, Math.min(24 * 60 - 30, Math.round((x / pph) * 60 / _EPG_SNAP_MIN) * _EPG_SNAP_MIN));
|
||||||
const start = new Date(dayStart);
|
const start = new Date(dayStart);
|
||||||
start.setMinutes(minutes);
|
start.setMinutes(minutes);
|
||||||
onEmptyClick(recorder, start);
|
onEmptyClick(recorder, start);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="epg-row" style={{ width: 24 * pph }} onClick={handleRowClick}>
|
<div className="epg-row" style={{ width: 24 * pph }} onPointerUp={handleRowPointerUp}>
|
||||||
{dayEvents.map(s => (
|
{dayEvents.map(s => (
|
||||||
<_EventBlock
|
<_EventBlock
|
||||||
key={s.id}
|
key={s.id}
|
||||||
|
|
@ -1077,12 +1180,49 @@ function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, on
|
||||||
pph={pph}
|
pph={pph}
|
||||||
now={now}
|
now={now}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
onClick={onEventClick} />
|
onClick={onEventClick}
|
||||||
|
onContextMenu={onEventContextMenu}
|
||||||
|
onResize={onEventResize} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Right-click menu for an EPG event block ─────────────────────────────────
|
||||||
|
// Same pattern as AssetContextMenu (screens-library.jsx): viewport-clamped,
|
||||||
|
// dismissed on outside click. Per-status action filtering mirrors the
|
||||||
|
// buttons rendered in the List view so the two surfaces stay consistent.
|
||||||
|
function _ScheduleContextMenu({ schedule, x, y, onClose, onEdit, onCancel, onDelete, onCopyId }) {
|
||||||
|
const ref = React.useRef(null);
|
||||||
|
const [pos, setPos] = React.useState({ left: x, top: y });
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const r = ref.current.getBoundingClientRect();
|
||||||
|
const margin = 8;
|
||||||
|
let nx = x, ny = y;
|
||||||
|
if (x + r.width + margin > window.innerWidth) nx = window.innerWidth - r.width - margin;
|
||||||
|
if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
|
||||||
|
setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
|
||||||
|
}, [x, y]);
|
||||||
|
|
||||||
|
const canEdit = schedule.status === 'pending' || schedule.status === 'failed';
|
||||||
|
const canCancel = schedule.status === 'pending' || schedule.status === 'running';
|
||||||
|
const canDelete = schedule.status !== 'running';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||||
|
<div className="ctx-header">{schedule.name}</div>
|
||||||
|
{canEdit && <button onClick={onEdit}><Icon name="edit" size={11} />Edit…</button>}
|
||||||
|
{canCancel && <button onClick={onCancel}><Icon name="x" size={11} />Cancel run</button>}
|
||||||
|
<button onClick={onCopyId}><Icon name="library" size={11} />Copy schedule ID</button>
|
||||||
|
{canDelete && <div className="ctx-divider" />}
|
||||||
|
{canDelete && <button className="danger" onClick={onDelete}><Icon name="trash" size={11} />Delete schedule</button>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function _NowLine({ now, dayStart, pph }) {
|
function _NowLine({ now, dayStart, pph }) {
|
||||||
if (!_sameDay(now, dayStart)) return null;
|
if (!_sameDay(now, dayStart)) return null;
|
||||||
const min = _minutesIntoDay(now, dayStart);
|
const min = _minutesIntoDay(now, dayStart);
|
||||||
|
|
@ -1121,6 +1261,7 @@ function Schedule({ navigate }) {
|
||||||
const [showNew, setShowNew] = React.useState(false);
|
const [showNew, setShowNew] = React.useState(false);
|
||||||
const [newDefaults, setNewDefaults] = React.useState(null);
|
const [newDefaults, setNewDefaults] = React.useState(null);
|
||||||
const [editing, setEditing] = React.useState(null);
|
const [editing, setEditing] = React.useState(null);
|
||||||
|
const [ctxMenu, setCtxMenu] = React.useState(null); // { schedule, x, y }
|
||||||
const [view, setView] = React.useState('today'); // 'today' | 'week' | 'list'
|
const [view, setView] = React.useState('today'); // 'today' | 'week' | 'list'
|
||||||
const [day, setDay] = React.useState(() => _dayStart(new Date()));
|
const [day, setDay] = React.useState(() => _dayStart(new Date()));
|
||||||
const [listFilter, setListFilter] = React.useState('upcoming');
|
const [listFilter, setListFilter] = React.useState('upcoming');
|
||||||
|
|
@ -1206,6 +1347,39 @@ function Schedule({ navigate }) {
|
||||||
window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' }).then(load).catch(e => alert('Delete failed: ' + e.message));
|
window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' }).then(load).catch(e => alert('Delete failed: ' + e.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Drag-resize commit: optimistically patch the in-memory schedule so the
|
||||||
|
// block stays put after the user lets go, then PUT the new times. The
|
||||||
|
// refetch reconciles in case the server adjusted anything (or rejected).
|
||||||
|
const handleResize = (s, newStart, newEnd) => {
|
||||||
|
setSchedules(prev => prev ? prev.map(x => x.id === s.id ? { ...x, start_at: newStart, end_at: newEnd } : x) : prev);
|
||||||
|
window.ZAMPP_API.fetch('/schedules/' + s.id, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ start_at: newStart, end_at: newEnd }),
|
||||||
|
})
|
||||||
|
.then(load)
|
||||||
|
.catch(e => { alert('Resize failed: ' + e.message); load(); });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCtx = (s, ev) => setCtxMenu({ schedule: s, x: ev.clientX, y: ev.clientY });
|
||||||
|
// Dismiss the context menu on any outside click — capture phase so a
|
||||||
|
// click on a menu item still fires before the menu unmounts.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!ctxMenu) return;
|
||||||
|
const close = () => setCtxMenu(null);
|
||||||
|
window.addEventListener('click', close);
|
||||||
|
window.addEventListener('contextmenu', close);
|
||||||
|
window.addEventListener('scroll', close, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('click', close);
|
||||||
|
window.removeEventListener('contextmenu', close);
|
||||||
|
window.removeEventListener('scroll', close, true);
|
||||||
|
};
|
||||||
|
}, [ctxMenu]);
|
||||||
|
|
||||||
|
const copyId = (id) => {
|
||||||
|
if (navigator.clipboard) navigator.clipboard.writeText(id).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
// Days for Week view: the 7-day window starting at the Sunday of `day`.
|
// Days for Week view: the 7-day window starting at the Sunday of `day`.
|
||||||
const weekDays = React.useMemo(() => {
|
const weekDays = React.useMemo(() => {
|
||||||
const sun = _addDays(dayStart, -dayStart.getDay());
|
const sun = _addDays(dayStart, -dayStart.getDay());
|
||||||
|
|
@ -1276,6 +1450,8 @@ function Schedule({ navigate }) {
|
||||||
now={now}
|
now={now}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
onEventClick={(s) => setEditing(s)}
|
onEventClick={(s) => setEditing(s)}
|
||||||
|
onEventContextMenu={openCtx}
|
||||||
|
onEventResize={handleResize}
|
||||||
onEmptyClick={openNewAt} />
|
onEmptyClick={openNewAt} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1308,6 +1484,8 @@ function Schedule({ navigate }) {
|
||||||
now={now}
|
now={now}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
onEventClick={(s) => setEditing(s)}
|
onEventClick={(s) => setEditing(s)}
|
||||||
|
onEventContextMenu={openCtx}
|
||||||
|
onEventResize={handleResize}
|
||||||
onEmptyClick={openNewAt} />
|
onEmptyClick={openNewAt} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1368,6 +1546,17 @@ function Schedule({ navigate }) {
|
||||||
onClose={() => { setShowNew(false); setNewDefaults(null); }}
|
onClose={() => { setShowNew(false); setNewDefaults(null); }}
|
||||||
onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />}
|
onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />}
|
||||||
{editing && <EditScheduleModal schedule={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load(); }} />}
|
{editing && <EditScheduleModal schedule={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load(); }} />}
|
||||||
|
{ctxMenu && (
|
||||||
|
<_ScheduleContextMenu
|
||||||
|
schedule={ctxMenu.schedule}
|
||||||
|
x={ctxMenu.x}
|
||||||
|
y={ctxMenu.y}
|
||||||
|
onClose={() => setCtxMenu(null)}
|
||||||
|
onEdit={() => { const s = ctxMenu.schedule; setCtxMenu(null); setEditing(s); }}
|
||||||
|
onCancel={() => { const s = ctxMenu.schedule; setCtxMenu(null); cancel(s); }}
|
||||||
|
onDelete={() => { const s = ctxMenu.schedule; setCtxMenu(null); remove(s); }}
|
||||||
|
onCopyId={() => { const id = ctxMenu.schedule.id; setCtxMenu(null); copyId(id); }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -833,17 +833,17 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
height: calc(var(--epg-row-h) - 16px);
|
height: calc(var(--epg-row-h) - 16px);
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: stretch;
|
||||||
padding: 0 10px 0 14px;
|
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: var(--text-1);
|
color: var(--text-1);
|
||||||
cursor: pointer;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: background 80ms, border-color 80ms, transform 80ms;
|
transition: background 80ms, border-color 80ms, transform 80ms, box-shadow 80ms;
|
||||||
|
/* Don't paint text selection while the operator drags the block. */
|
||||||
|
user-select: none; -webkit-user-select: none;
|
||||||
}
|
}
|
||||||
.epg-block:hover {
|
.epg-block:hover {
|
||||||
background: var(--bg-3);
|
background: var(--bg-3);
|
||||||
|
|
@ -851,12 +851,28 @@
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
.epg-block.dragging {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--epg-block-color, var(--accent));
|
||||||
|
transition: none; /* follow cursor 1:1, no easing during drag */
|
||||||
|
}
|
||||||
.epg-block-bar {
|
.epg-block-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0; top: 0; bottom: 0;
|
left: 0; top: 0; bottom: 0;
|
||||||
width: 4px;
|
width: 4px;
|
||||||
background: var(--epg-block-color, var(--accent));
|
background: var(--epg-block-color, var(--accent));
|
||||||
|
pointer-events: none; /* let the resize handle behind catch pointerdown */
|
||||||
}
|
}
|
||||||
|
.epg-block-body {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 0 10px 0 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.epg-block.resizable .epg-block-body { cursor: grab; }
|
||||||
|
.epg-block.dragging .epg-block-body { cursor: grabbing; }
|
||||||
.epg-block-name {
|
.epg-block-name {
|
||||||
flex: 1; min-width: 0;
|
flex: 1; min-width: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -866,6 +882,31 @@
|
||||||
font-size: 10px; color: var(--text-3);
|
font-size: 10px; color: var(--text-3);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Resize handles. 8px-wide invisible-by-default hit zones at each end;
|
||||||
|
a 2px tinted bar fades in on hover so the operator sees where the
|
||||||
|
resize affordance is. Hides for non-pending blocks (no .resizable). */
|
||||||
|
.epg-block-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; bottom: 0;
|
||||||
|
width: 8px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.epg-block-handle.left { left: 0; }
|
||||||
|
.epg-block-handle.right { right: 0; }
|
||||||
|
.epg-block-handle::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute; top: 6px; bottom: 6px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--epg-block-color, var(--accent));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 80ms;
|
||||||
|
}
|
||||||
|
.epg-block-handle.left::after { left: 2px; }
|
||||||
|
.epg-block-handle.right::after { right: 2px; }
|
||||||
|
.epg-block-handle:hover::after,
|
||||||
|
.epg-block.dragging .epg-block-handle::after { opacity: 0.85; }
|
||||||
.epg-block-glyph {
|
.epg-block-glyph {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue