fix: v4.5.0 - cross-day drag (ghost element, no overflow issue); adaptive block labels; PDF fit_text truncation
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Some checks failed
Build & Push Docker / build (push) Has been cancelled
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
"""Application configuration."""
|
"""Application configuration."""
|
||||||
|
|
||||||
VERSION = "4.4.0"
|
VERSION = "4.5.0"
|
||||||
MAX_FILE_SIZE_MB = 10
|
MAX_FILE_SIZE_MB = 10
|
||||||
DEFAULT_COLOR = "#ffffff"
|
DEFAULT_COLOR = "#ffffff"
|
||||||
|
|||||||
@@ -153,6 +153,25 @@ def draw_clipped_text(c, text, x, y, w, h, font, size, rgb, align='center'):
|
|||||||
c.restoreState()
|
c.restoreState()
|
||||||
|
|
||||||
|
|
||||||
|
def fit_text(c, text: str, font: str, size: float, max_w: float) -> str:
|
||||||
|
"""Truncate text with ellipsis so it fits within max_w points."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
if c.stringWidth(text, font, size) <= max_w:
|
||||||
|
return text
|
||||||
|
# Binary-search trim
|
||||||
|
ellipsis = '…'
|
||||||
|
ellipsis_w = c.stringWidth(ellipsis, font, size)
|
||||||
|
lo, hi = 0, len(text)
|
||||||
|
while lo < hi:
|
||||||
|
mid = (lo + hi + 1) // 2
|
||||||
|
if c.stringWidth(text[:mid], font, size) + ellipsis_w <= max_w:
|
||||||
|
lo = mid
|
||||||
|
else:
|
||||||
|
hi = mid - 1
|
||||||
|
return (text[:lo] + ellipsis) if lo < len(text) else text
|
||||||
|
|
||||||
|
|
||||||
def generate_pdf(doc) -> bytes:
|
def generate_pdf(doc) -> bytes:
|
||||||
if not doc.blocks:
|
if not doc.blocks:
|
||||||
raise ScenarsError("No blocks provided")
|
raise ScenarsError("No blocks provided")
|
||||||
@@ -373,19 +392,29 @@ def generate_pdf(doc) -> bytes:
|
|||||||
dim_rgb = ((text_rgb[0] * 0.78, text_rgb[1] * 0.78, text_rgb[2] * 0.78)
|
dim_rgb = ((text_rgb[0] * 0.78, text_rgb[1] * 0.78, text_rgb[2] * 0.78)
|
||||||
if is_light(pt.color) else (0.82, 0.82, 0.82))
|
if is_light(pt.color) else (0.82, 0.82, 0.82))
|
||||||
|
|
||||||
# Determine vertical layout: how many lines fit?
|
# Available width for text (inset + 2pt padding each side)
|
||||||
has_responsible = bool(block.responsible)
|
text_w_avail = max(1.0, bw - 2 * inset - 4)
|
||||||
|
|
||||||
sup_size = max(4.0, block_title_font * 0.65)
|
sup_size = max(4.0, block_title_font * 0.65)
|
||||||
resp_size = max(4.0, block_time_font)
|
resp_size = max(4.0, block_time_font)
|
||||||
|
|
||||||
|
# Truncate title to fit (leave room for superscript number)
|
||||||
|
sup_reserve = (c.stringWidth(str(fn_num), _FONT_BOLD, sup_size) + 1.5
|
||||||
|
if fn_num else 0)
|
||||||
|
fitted_title = fit_text(c, title_text, _FONT_BOLD, block_title_font,
|
||||||
|
text_w_avail - sup_reserve)
|
||||||
|
|
||||||
|
# Determine vertical layout: how many lines fit?
|
||||||
|
has_responsible = bool(block.responsible)
|
||||||
if has_responsible and row_h >= block_title_font + resp_size + 3:
|
if has_responsible and row_h >= block_title_font + resp_size + 3:
|
||||||
# Two-line: title (with superscript) + responsible
|
# Two-line: title + responsible
|
||||||
title_y = row_y + row_h * 0.55
|
title_y = row_y + row_h * 0.55
|
||||||
resp_y = row_y + row_h * 0.55 - block_title_font - 1
|
resp_y = row_y + row_h * 0.55 - block_title_font - 1
|
||||||
# responsible
|
fitted_resp = fit_text(c, block.responsible, _FONT_ITALIC, resp_size,
|
||||||
|
text_w_avail)
|
||||||
c.setFont(_FONT_ITALIC, resp_size)
|
c.setFont(_FONT_ITALIC, resp_size)
|
||||||
set_fill(c, dim_rgb)
|
set_fill(c, dim_rgb)
|
||||||
c.drawCentredString(bx + bw / 2, resp_y, block.responsible)
|
c.drawCentredString(bx + bw / 2, resp_y, fitted_resp)
|
||||||
else:
|
else:
|
||||||
# Single line: title centred
|
# Single line: title centred
|
||||||
title_y = row_y + (row_h - block_title_font) / 2
|
title_y = row_y + (row_h - block_title_font) / 2
|
||||||
@@ -395,16 +424,15 @@ def generate_pdf(doc) -> bytes:
|
|||||||
set_fill(c, text_rgb)
|
set_fill(c, text_rgb)
|
||||||
if fn_num is not None:
|
if fn_num is not None:
|
||||||
# Draw title then superscript footnote number
|
# Draw title then superscript footnote number
|
||||||
title_w = c.stringWidth(title_text, _FONT_BOLD, block_title_font)
|
title_w = c.stringWidth(fitted_title, _FONT_BOLD, block_title_font)
|
||||||
tx = bx + bw / 2 - title_w / 2
|
tx = bx + bw / 2 - (title_w + sup_reserve) / 2
|
||||||
c.drawString(tx, title_y, title_text)
|
c.drawString(tx, title_y, fitted_title)
|
||||||
# Superscript: small number raised by ~font_size * 0.5
|
|
||||||
c.setFont(_FONT_BOLD, sup_size)
|
c.setFont(_FONT_BOLD, sup_size)
|
||||||
set_fill(c, dim_rgb)
|
set_fill(c, dim_rgb)
|
||||||
c.drawString(tx + title_w + 0.5, title_y + block_title_font * 0.45,
|
c.drawString(tx + title_w + 0.5, title_y + block_title_font * 0.45,
|
||||||
str(fn_num))
|
str(fn_num))
|
||||||
else:
|
else:
|
||||||
c.drawCentredString(bx + bw / 2, title_y, title_text)
|
c.drawCentredString(bx + bw / 2, title_y, fitted_title)
|
||||||
|
|
||||||
c.restoreState()
|
c.restoreState()
|
||||||
|
|
||||||
|
|||||||
@@ -768,12 +768,6 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Allow blocks to visually leave their row during cross-day drag */
|
|
||||||
.day-rows.dragging .day-row,
|
|
||||||
.day-rows.dragging .day-timeline {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Highlight the row being dragged over */
|
/* Highlight the row being dragged over */
|
||||||
.day-timeline.drag-target {
|
.day-timeline.drag-target {
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
@@ -781,6 +775,13 @@ body {
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Floating ghost shown during cross-day drag */
|
||||||
|
.drag-ghost {
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.day-label {
|
.day-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="header-title">Scénář Creator</h1>
|
<h1 class="header-title">Scénář Creator</h1>
|
||||||
<span class="header-version">v4.4</span>
|
<span class="header-version">v4.5</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<label class="btn btn-secondary btn-sm" id="importJsonBtn">
|
<label class="btn btn-secondary btn-sm" id="importJsonBtn">
|
||||||
|
|||||||
@@ -229,28 +229,35 @@ const Canvas = {
|
|||||||
position:absolute;
|
position:absolute;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Block label
|
// Block label — adaptive based on available width
|
||||||
|
const widthPx = (ce - cs) * this.pxPerMinute;
|
||||||
const inner = document.createElement('div');
|
const inner = document.createElement('div');
|
||||||
inner.className = 'block-inner';
|
inner.className = 'block-inner';
|
||||||
const timeLabel = `${block.start}–${block.end}`;
|
|
||||||
|
|
||||||
|
if (widthPx >= 28) {
|
||||||
const nameEl = document.createElement('span');
|
const nameEl = document.createElement('span');
|
||||||
nameEl.className = 'block-title';
|
nameEl.className = 'block-title';
|
||||||
nameEl.textContent = block.title + (block.notes ? ' *' : '');
|
nameEl.textContent = block.title + (block.notes ? ' *' : '');
|
||||||
|
inner.appendChild(nameEl);
|
||||||
|
|
||||||
|
if (widthPx >= 72) {
|
||||||
|
const timeLabel = `${block.start}–${block.end}`;
|
||||||
const timeEl = document.createElement('span');
|
const timeEl = document.createElement('span');
|
||||||
timeEl.className = 'block-time';
|
timeEl.className = 'block-time';
|
||||||
timeEl.textContent = timeLabel + (isOvernight ? ' →' : '');
|
timeEl.textContent = timeLabel + (isOvernight ? ' →' : '');
|
||||||
|
|
||||||
inner.appendChild(nameEl);
|
|
||||||
inner.appendChild(timeEl);
|
inner.appendChild(timeEl);
|
||||||
|
}
|
||||||
|
|
||||||
if (block.responsible) {
|
if (widthPx >= 90 && block.responsible) {
|
||||||
const respEl = document.createElement('span');
|
const respEl = document.createElement('span');
|
||||||
respEl.className = 'block-responsible';
|
respEl.className = 'block-responsible';
|
||||||
respEl.textContent = block.responsible;
|
respEl.textContent = block.responsible;
|
||||||
inner.appendChild(respEl);
|
inner.appendChild(respEl);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Tooltip always available for narrow blocks
|
||||||
|
el.title = `${block.title} (${block.start}–${block.end})` +
|
||||||
|
(block.responsible ? ` · ${block.responsible}` : '');
|
||||||
el.appendChild(inner);
|
el.appendChild(inner);
|
||||||
|
|
||||||
// Click to edit
|
// Click to edit
|
||||||
@@ -259,24 +266,18 @@ const Canvas = {
|
|||||||
App.openBlockModal(block.id);
|
App.openBlockModal(block.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// interact.js drag (X = time, Y = cross-day)
|
// Native pointer drag — ghost on document.body, avoids overflow/clipping issues
|
||||||
|
el.addEventListener('pointerdown', (e) => {
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
if (e.clientX > elRect.right - 8) return; // right 8px = resize handle
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this._startPointerDrag(e, el, block);
|
||||||
|
});
|
||||||
|
|
||||||
|
// interact.js resize only (right edge)
|
||||||
if (window.interact) {
|
if (window.interact) {
|
||||||
interact(el)
|
interact(el)
|
||||||
.draggable({
|
|
||||||
listeners: {
|
|
||||||
start: (event) => this._onDragStart(event, block),
|
|
||||||
move: (event) => this._onDragMove(event, block),
|
|
||||||
end: (event) => this._onDragEnd(event, block),
|
|
||||||
},
|
|
||||||
modifiers: [
|
|
||||||
interact.modifiers.snap({
|
|
||||||
targets: [interact.snappers.grid({ x: this._minutesPx(this.GRID_MINUTES), y: 10000 })],
|
|
||||||
range: Infinity,
|
|
||||||
relativePoints: [{ x: 0, y: 0 }],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
inertia: false,
|
|
||||||
})
|
|
||||||
.resizable({
|
.resizable({
|
||||||
edges: { right: true },
|
edges: { right: true },
|
||||||
listeners: {
|
listeners: {
|
||||||
@@ -299,70 +300,82 @@ const Canvas = {
|
|||||||
return minutes * this.pxPerMinute;
|
return minutes * this.pxPerMinute;
|
||||||
},
|
},
|
||||||
|
|
||||||
_onDragStart(event, block) {
|
// Native pointer drag — creates a ghost on document.body so clipping never happens
|
||||||
const target = event.target;
|
_startPointerDrag(e, el, block) {
|
||||||
target.dataset.dy = '0';
|
const startX = e.clientX;
|
||||||
target.style.zIndex = '500';
|
const startY = e.clientY;
|
||||||
// Enable cross-row overflow while dragging
|
const elRect = el.getBoundingClientRect();
|
||||||
const dayRows = document.getElementById('dayRows');
|
const startMin = this.parseTime(block.start);
|
||||||
if (dayRows) dayRows.classList.add('dragging');
|
const duration = this.parseTime(block.end) - startMin;
|
||||||
},
|
const snapGrid = this._minutesPx(this.GRID_MINUTES);
|
||||||
|
|
||||||
_onDragMove(event, block) {
|
// Create floating ghost
|
||||||
const target = event.target;
|
const ghost = document.createElement('div');
|
||||||
|
ghost.className = 'block-el drag-ghost';
|
||||||
|
ghost.innerHTML = el.innerHTML;
|
||||||
|
Object.assign(ghost.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
left: elRect.left + 'px',
|
||||||
|
top: elRect.top + 'px',
|
||||||
|
width: elRect.width + 'px',
|
||||||
|
height: elRect.height + 'px',
|
||||||
|
background: el.style.background,
|
||||||
|
color: el.style.color,
|
||||||
|
zIndex: '9999',
|
||||||
|
opacity: '0.88',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
cursor: 'grabbing',
|
||||||
|
boxShadow: '0 6px 20px rgba(0,0,0,0.28)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'none',
|
||||||
|
margin: '0',
|
||||||
|
});
|
||||||
|
document.body.appendChild(ghost);
|
||||||
|
el.style.opacity = '0.25'; // dim original, don't hide (keeps layout)
|
||||||
|
|
||||||
// ── X axis: update time ──────────────────────────────────────
|
const clearHighlights = () =>
|
||||||
const deltaMin = event.dx / this.pxPerMinute;
|
|
||||||
const duration = this.parseTime(block.end) - this.parseTime(block.start);
|
|
||||||
const newStartMin = this.snapMinutes(this.parseTime(block.start) + deltaMin);
|
|
||||||
const clampedStart = Math.max(this._startMin, Math.min(this._endMin - duration, newStartMin));
|
|
||||||
block.start = this.formatTime(clampedStart);
|
|
||||||
block.end = this.formatTime(clampedStart + duration);
|
|
||||||
const totalRange = this._endMin - this._startMin;
|
|
||||||
target.style.left = ((clampedStart - this._startMin) / totalRange * 100) + '%';
|
|
||||||
|
|
||||||
// ── Y axis: visual shift + row highlight ─────────────────────
|
|
||||||
const dy = (parseFloat(target.dataset.dy) || 0) + event.dy;
|
|
||||||
target.dataset.dy = dy;
|
|
||||||
target.style.transform = `translateY(${dy}px)`;
|
|
||||||
|
|
||||||
this._highlightTargetRow(target);
|
|
||||||
},
|
|
||||||
|
|
||||||
_onDragEnd(event, block) {
|
|
||||||
const target = event.target;
|
|
||||||
|
|
||||||
// Detect which day-timeline the block's vertical center is over
|
|
||||||
const rect = target.getBoundingClientRect();
|
|
||||||
const centerY = rect.top + rect.height / 2;
|
|
||||||
for (const row of document.querySelectorAll('.day-timeline')) {
|
|
||||||
const rr = row.getBoundingClientRect();
|
|
||||||
if (centerY >= rr.top && centerY <= rr.bottom) {
|
|
||||||
block.date = row.dataset.date;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
document.querySelectorAll('.day-timeline.drag-target')
|
document.querySelectorAll('.day-timeline.drag-target')
|
||||||
.forEach(r => r.classList.remove('drag-target'));
|
.forEach(r => r.classList.remove('drag-target'));
|
||||||
const dayRows = document.getElementById('dayRows');
|
|
||||||
if (dayRows) dayRows.classList.remove('dragging');
|
|
||||||
target.style.transform = '';
|
|
||||||
target.style.zIndex = '';
|
|
||||||
delete target.dataset.dy;
|
|
||||||
|
|
||||||
|
const onMove = (ev) => {
|
||||||
|
const dx = ev.clientX - startX;
|
||||||
|
const dy = ev.clientY - startY;
|
||||||
|
ghost.style.left = (elRect.left + dx) + 'px';
|
||||||
|
ghost.style.top = (elRect.top + dy) + 'px';
|
||||||
|
|
||||||
|
// Highlight target row — elementFromPoint sees through ghost (pointer-events:none)
|
||||||
|
clearHighlights();
|
||||||
|
const under = document.elementFromPoint(ev.clientX, ev.clientY);
|
||||||
|
under?.closest('.day-timeline')?.classList.add('drag-target');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = (ev) => {
|
||||||
|
document.removeEventListener('pointermove', onMove);
|
||||||
|
document.removeEventListener('pointerup', onUp);
|
||||||
|
|
||||||
|
const dx = ev.clientX - startX;
|
||||||
|
|
||||||
|
// Snap X to 15-min grid
|
||||||
|
const snappedDx = Math.round(dx / snapGrid) * snapGrid;
|
||||||
|
const deltaMin = snappedDx / this.pxPerMinute;
|
||||||
|
const newStart = this.snapMinutes(startMin + deltaMin);
|
||||||
|
const clamped = Math.max(this._startMin, Math.min(this._endMin - duration, newStart));
|
||||||
|
block.start = this.formatTime(clamped);
|
||||||
|
block.end = this.formatTime(clamped + duration);
|
||||||
|
|
||||||
|
// Determine target day
|
||||||
|
const under = document.elementFromPoint(ev.clientX, ev.clientY);
|
||||||
|
const timeline = under?.closest('.day-timeline');
|
||||||
|
if (timeline?.dataset.date) block.date = timeline.dataset.date;
|
||||||
|
|
||||||
|
ghost.remove();
|
||||||
|
el.style.opacity = '';
|
||||||
|
clearHighlights();
|
||||||
App.renderCanvas();
|
App.renderCanvas();
|
||||||
},
|
};
|
||||||
|
|
||||||
_highlightTargetRow(el) {
|
document.addEventListener('pointermove', onMove);
|
||||||
const rect = el.getBoundingClientRect();
|
document.addEventListener('pointerup', onUp);
|
||||||
const centerY = rect.top + rect.height / 2;
|
|
||||||
document.querySelectorAll('.day-timeline').forEach(row => {
|
|
||||||
const rr = row.getBoundingClientRect();
|
|
||||||
const isTarget = centerY >= rr.top && centerY <= rr.bottom;
|
|
||||||
row.classList.toggle('drag-target', isTarget);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_onResizeMove(event, block) {
|
_onResizeMove(event, block) {
|
||||||
|
|||||||
Reference in New Issue
Block a user