bak
This commit is contained in:
1 parent
0d4d369870
commit
9c71e65041
9 files changed
+423
-16
No files matched your search
+113
-1
@@ -36,6 +36,7 @@ body::after {
|
||||
|
||||
/* Cards */
|
||||
.card-bar { image-rendering: pixelated; height: 3px; }
|
||||
.board-card { min-height: 180px; }
|
||||
.card-layer { position: relative; z-index: 1; }
|
||||
.card-has-cover {
|
||||
background-size: cover;
|
||||
@@ -43,6 +44,9 @@ body::after {
|
||||
background-repeat: no-repeat;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-group.card-has-cover {
|
||||
overflow: visible;
|
||||
}
|
||||
.card-cover-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -74,7 +78,10 @@ body::after {
|
||||
right: -6px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -185,6 +192,61 @@ body.detail-zen-open { overflow: hidden; }
|
||||
.overlay:not(.open) { opacity: 0; pointer-events: none; }
|
||||
.overlay.open { opacity: 1; }
|
||||
|
||||
/* Detail image fullscreen */
|
||||
body.detail-image-fs-open { overflow: hidden; }
|
||||
#detail-body img { cursor: zoom-in; }
|
||||
.detail-image-fs-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 170;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(2, 6, 23, 0.94);
|
||||
}
|
||||
.detail-image-fs-overlay.open { display: flex; }
|
||||
.detail-image-fs-stage {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.detail-image-fs-img {
|
||||
max-width: min(96vw, 1900px);
|
||||
max-height: 94vh;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--t-border);
|
||||
box-shadow: 0 16px 60px rgba(0, 0, 0, 0.55);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
will-change: transform;
|
||||
}
|
||||
.detail-image-fs-overlay.dragging .detail-image-fs-img { cursor: grabbing; }
|
||||
.detail-image-fs-close {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 171;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||||
transition: color 0.12s, transform 0.12s;
|
||||
}
|
||||
.detail-image-fs-close:hover {
|
||||
color: var(--t-accent);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
/* Theme picker */
|
||||
.theme-picker { position: relative; }
|
||||
.theme-menu { display: none; position: absolute; bottom: calc(100% + 8px); right: 0; z-index: 200; }
|
||||
@@ -201,6 +263,14 @@ body.detail-zen-open { overflow: hidden; }
|
||||
}
|
||||
.bucket-tab.drag-over i,
|
||||
.bucket-tab.drag-over span { color: inherit !important; }
|
||||
.bucket-add.drag-over {
|
||||
background: var(--t-accent) !important;
|
||||
color: var(--t-bg) !important;
|
||||
transform: scale(1.05);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.bucket-add.drag-over i,
|
||||
.bucket-add.drag-over span { color: inherit !important; }
|
||||
.bucket-drop-left { border-left: 3px solid var(--t-accent) !important; }
|
||||
.bucket-drop-right { border-right: 3px solid var(--t-accent) !important; }
|
||||
|
||||
@@ -321,3 +391,45 @@ body.detail-zen-open { overflow: hidden; }
|
||||
.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
|
||||
.prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; }
|
||||
.prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; }
|
||||
|
||||
/* Temporary reading aid in open detail view (not persisted) */
|
||||
#detail-body .detail-reading-resize {
|
||||
position: relative;
|
||||
padding-bottom: 1rem;
|
||||
min-height: 1.8em;
|
||||
}
|
||||
#detail-body .detail-reading-resize:hover,
|
||||
#detail-body .detail-reading-resize.resizing {
|
||||
outline: 1px dashed var(--t-border);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
#detail-body .detail-font-dragline {
|
||||
position: absolute;
|
||||
left: 0.4rem;
|
||||
right: 0.4rem;
|
||||
bottom: 0.2rem;
|
||||
height: 10px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
#detail-body .detail-font-dragline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 4px;
|
||||
border-top: 1px dashed var(--t-border);
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
#detail-body .detail-reading-resize:hover .detail-font-dragline,
|
||||
#detail-body .detail-reading-resize.resizing .detail-font-dragline {
|
||||
opacity: 1;
|
||||
}
|
||||
#detail-body .detail-font-dragline:hover::before,
|
||||
#detail-body .detail-reading-resize.resizing .detail-font-dragline::before {
|
||||
border-color: var(--t-accent);
|
||||
}
|
||||
+5
-2
@@ -141,8 +141,11 @@ if (str_starts_with($route, '/api/')) {
|
||||
$title = trim($input['title'] ?? '');
|
||||
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
|
||||
$pos = $input['position'] ?? 0;
|
||||
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, position) VALUES (:title, :body, :color, :bucket_id, :position)')->execute([
|
||||
'title' => $title, 'body' => $input['body'] ?? '', 'color' => validColor($input['color'] ?? null), 'bucket_id' => validInt($input['bucket_id'] ?? null, null), 'position' => validInt($pos),
|
||||
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position)')->execute([
|
||||
'title' => $title, 'body' => $input['body'] ?? '', 'color' => validColor($input['color'] ?? null),
|
||||
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
|
||||
'parent_id' => validInt($input['parent_id'] ?? null, null),
|
||||
'position' => validInt($pos),
|
||||
]);
|
||||
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch());
|
||||
exit;
|
||||
|
||||
+2
-1
@@ -13,13 +13,14 @@ import { openModal, closeModal, saveCard } from './modules/editor.js';
|
||||
import { openSearch, closeSearch } from './modules/search.js';
|
||||
import { showContextMenu } from './modules/context-menu.js';
|
||||
import { undo, canUndo } from './modules/undo.js';
|
||||
import { closeGallery } from './modules/gallery.js';
|
||||
import { openGallery, closeGallery } from './modules/gallery.js';
|
||||
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
||||
|
||||
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
||||
|
||||
window._openDetail = openDetail;
|
||||
window._openModal = openModal;
|
||||
window._openGallery = openGallery;
|
||||
window._closeSearch = closeSearch;
|
||||
window._closeTasks = closeTasks;
|
||||
window._renderBoard = renderBoard;
|
||||
|
||||
@@ -79,11 +79,11 @@ export function renderBoard() {
|
||||
const hasCover = !!coverImg;
|
||||
|
||||
return `
|
||||
<div class="${isGroup ? 'card-group ' : ''}card-glow ${hasCover ? 'card-has-cover' : 'bg-t-surface'} border border-t-border cursor-pointer hover:border-t-muted transition-colors relative group"
|
||||
<div class="board-card ${isGroup ? 'card-group ' : ''}card-glow ${hasCover ? 'card-has-cover' : 'bg-t-surface'} border border-t-border cursor-pointer hover:border-t-muted transition-colors relative group"
|
||||
style="--glow-color:${c.color}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + c.color : ''}" draggable="true" data-id="${c.id}">
|
||||
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
|
||||
<div class="card-bar w-full card-layer" style="background:${c.color}"></div>
|
||||
${isGroup ? `<div class="card-group-badge font-pixel cursor-pointer hover:scale-110 transition-transform" style="background:${c.color};color:#000" data-group="${c.id}">${children.length + 1}</div>` : ''}
|
||||
${isGroup ? `<div class="card-group-badge cursor-pointer hover:scale-110 transition-transform" style="background:${c.color};color:#000" data-group="${c.id}">${children.length + 1}</div>` : ''}
|
||||
${thumbImg ? `<div class="card-thumb" style="background-image:url('${thumbImg}')"></div>` : ''}
|
||||
<div class="p-4 card-layer ${thumbImg ? 'pr-16' : ''}">
|
||||
<div class="flex items-start gap-2 mb-2">
|
||||
|
||||
@@ -121,6 +121,15 @@ export function populateSelects() {
|
||||
}
|
||||
}
|
||||
|
||||
function autoBucketNameFromCard(card) {
|
||||
const existing = new Set(state.buckets.map(b => String(b.name || '').trim().toLowerCase()).filter(Boolean));
|
||||
const base = String(card?.title || '').trim().replace(/\s+/g, ' ').slice(0, 40) || 'bucket';
|
||||
if (!existing.has(base.toLowerCase())) return base;
|
||||
let i = 2;
|
||||
while (existing.has(`${base} ${i}`.toLowerCase())) i++;
|
||||
return `${base} ${i}`;
|
||||
}
|
||||
|
||||
function updateHeaderTint() {
|
||||
const tint = $('header-tint');
|
||||
if (!tint) return;
|
||||
@@ -145,7 +154,7 @@ function renderBar() {
|
||||
const active = state.activeBucket == b.id;
|
||||
return `<button class="${tab(active)}" data-bucket="${b.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${active ? `border-color:${c};color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`;
|
||||
}).join('')}
|
||||
<button id="add-bucket-btn" class="flex items-center gap-1 px-2 py-1 text-xs text-t-dim hover:text-t-accent transition-colors shrink-0"><i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">bucket</span></button>`;
|
||||
<button id="add-bucket-btn" class="bucket-add flex items-center gap-1 px-2 py-1 text-xs text-t-dim hover:text-t-accent transition-colors shrink-0"><i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">bucket</span></button>`;
|
||||
|
||||
updateHeaderTint();
|
||||
|
||||
@@ -223,11 +232,34 @@ function renderBar() {
|
||||
});
|
||||
});
|
||||
|
||||
$('add-bucket-btn')?.addEventListener('click', async () => {
|
||||
const addBtn = $('add-bucket-btn');
|
||||
addBtn?.addEventListener('click', async () => {
|
||||
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
|
||||
if (!name || !name.trim()) return;
|
||||
await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
||||
await loadBuckets();
|
||||
window._renderBoard();
|
||||
});
|
||||
|
||||
const isCardDrag = e => Array.from(e.dataTransfer?.types || []).includes('text/plain');
|
||||
|
||||
addBtn?.addEventListener('dragover', e => {
|
||||
if (!isCardDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
addBtn.classList.add('drag-over');
|
||||
});
|
||||
addBtn?.addEventListener('dragleave', () => addBtn.classList.remove('drag-over'));
|
||||
addBtn?.addEventListener('drop', async e => {
|
||||
addBtn.classList.remove('drag-over');
|
||||
const cardId = e.dataTransfer.getData('text/plain');
|
||||
if (!cardId) return;
|
||||
e.preventDefault();
|
||||
const card = state.cards.find(c => String(c.id) === String(cardId));
|
||||
const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } });
|
||||
if (!created?.id) return;
|
||||
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: created.id } });
|
||||
await loadBuckets();
|
||||
await window._reloadAll();
|
||||
});
|
||||
}
|
||||
+241
-4
@@ -13,6 +13,18 @@ const bodyEl = $('detail-body');
|
||||
const metaEl = $('detail-meta');
|
||||
let morphTimer = null;
|
||||
let morphGhost = null;
|
||||
let imageFsOverlay = null;
|
||||
let imageFsImg = null;
|
||||
let fsScale = 1;
|
||||
let fsTx = 0;
|
||||
let fsTy = 0;
|
||||
let fsPanning = false;
|
||||
let fsStartX = 0;
|
||||
let fsStartY = 0;
|
||||
let fsStartTx = 0;
|
||||
let fsStartTy = 0;
|
||||
const FS_MIN_SCALE = 0.35;
|
||||
const FS_MAX_SCALE = 8;
|
||||
|
||||
// ── Zoom ──
|
||||
|
||||
@@ -41,7 +53,7 @@ scroll?.addEventListener('wheel', e => {
|
||||
|
||||
// ── Open / close ──
|
||||
|
||||
export function openDetail(id) {
|
||||
export function openDetail(id, options = {}) {
|
||||
const card = state.cards.find(c => c.id == id);
|
||||
if (!card) return;
|
||||
markRecent(id);
|
||||
@@ -51,6 +63,11 @@ export function openDetail(id) {
|
||||
|
||||
if (!detail.classList.contains('open')) {
|
||||
state.openedFromSearch = $('search')?.classList.contains('open') || false;
|
||||
state.openedFromGallery = !!options.fromGallery;
|
||||
state.galleryOrigin = options.fromGallery ? (options.galleryOrigin || null) : null;
|
||||
} else if (options.fromGallery) {
|
||||
state.openedFromGallery = true;
|
||||
state.galleryOrigin = options.galleryOrigin || state.galleryOrigin || null;
|
||||
}
|
||||
state.viewingCard = card;
|
||||
|
||||
@@ -70,7 +87,139 @@ function clearMorphGhost() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureImageFullscreenOverlay() {
|
||||
if (imageFsOverlay) return imageFsOverlay;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'detail-image-fs-overlay';
|
||||
overlay.innerHTML = `
|
||||
<button type="button" class="detail-image-fs-close" aria-label="close image">
|
||||
<i class="ph ph-x text-xl"></i>
|
||||
</button>
|
||||
<div class="detail-image-fs-stage">
|
||||
<img class="detail-image-fs-img" alt="">
|
||||
</div>
|
||||
`;
|
||||
overlay.addEventListener('click', e => {
|
||||
if (e.target === overlay) closeImageFullscreen();
|
||||
});
|
||||
overlay.querySelector('.detail-image-fs-close')?.addEventListener('click', closeImageFullscreen);
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
imageFsOverlay = overlay;
|
||||
imageFsImg = overlay.querySelector('.detail-image-fs-img');
|
||||
if (imageFsImg) {
|
||||
imageFsImg.draggable = false;
|
||||
imageFsImg.addEventListener('mousedown', onImageFsMouseDown);
|
||||
}
|
||||
overlay.addEventListener('wheel', onImageFsWheel, { passive: false });
|
||||
document.addEventListener('mousemove', onImageFsMouseMove);
|
||||
document.addEventListener('mouseup', onImageFsMouseUp);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function applyImageFsTransform() {
|
||||
if (!imageFsImg) return;
|
||||
imageFsImg.style.transform = `translate(${fsTx}px, ${fsTy}px) scale(${fsScale})`;
|
||||
}
|
||||
|
||||
function resetImageFsTransform() {
|
||||
fsScale = 1;
|
||||
fsTx = 0;
|
||||
fsTy = 0;
|
||||
fsPanning = false;
|
||||
imageFsOverlay?.classList.remove('dragging');
|
||||
applyImageFsTransform();
|
||||
}
|
||||
|
||||
function onImageFsWheel(e) {
|
||||
if (!imageFsOverlay?.classList.contains('open') || !imageFsImg) return;
|
||||
e.preventDefault();
|
||||
|
||||
const old = fsScale;
|
||||
const factor = Math.exp(-e.deltaY * 0.0015);
|
||||
const next = Math.min(FS_MAX_SCALE, Math.max(FS_MIN_SCALE, old * factor));
|
||||
if (Math.abs(next - old) < 0.0001) return;
|
||||
|
||||
const r = imageFsOverlay.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2;
|
||||
const cy = r.top + r.height / 2;
|
||||
const px = e.clientX - cx;
|
||||
const py = e.clientY - cy;
|
||||
|
||||
fsTx = px - ((px - fsTx) / old) * next;
|
||||
fsTy = py - ((py - fsTy) / old) * next;
|
||||
fsScale = next;
|
||||
applyImageFsTransform();
|
||||
}
|
||||
|
||||
function onImageFsMouseDown(e) {
|
||||
if (!imageFsOverlay?.classList.contains('open')) return;
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
fsPanning = true;
|
||||
fsStartX = e.clientX;
|
||||
fsStartY = e.clientY;
|
||||
fsStartTx = fsTx;
|
||||
fsStartTy = fsTy;
|
||||
imageFsOverlay.classList.add('dragging');
|
||||
}
|
||||
|
||||
function onImageFsMouseMove(e) {
|
||||
if (!fsPanning || !imageFsOverlay?.classList.contains('open')) return;
|
||||
fsTx = fsStartTx + (e.clientX - fsStartX);
|
||||
fsTy = fsStartTy + (e.clientY - fsStartY);
|
||||
applyImageFsTransform();
|
||||
}
|
||||
|
||||
function onImageFsMouseUp() {
|
||||
if (!fsPanning) return;
|
||||
fsPanning = false;
|
||||
imageFsOverlay?.classList.remove('dragging');
|
||||
}
|
||||
|
||||
function openImageFullscreen(src, alt = '') {
|
||||
if (!src) return;
|
||||
const overlay = ensureImageFullscreenOverlay();
|
||||
resetImageFsTransform();
|
||||
if (imageFsImg) {
|
||||
imageFsImg.src = src;
|
||||
imageFsImg.alt = alt || 'image';
|
||||
}
|
||||
overlay.classList.add('open');
|
||||
document.body.classList.add('detail-image-fs-open');
|
||||
}
|
||||
|
||||
function closeImageFullscreen() {
|
||||
if (!imageFsOverlay) return;
|
||||
fsPanning = false;
|
||||
imageFsOverlay.classList.remove('dragging');
|
||||
imageFsOverlay.classList.remove('open');
|
||||
document.body.classList.remove('detail-image-fs-open');
|
||||
if (imageFsImg) {
|
||||
imageFsImg.removeAttribute('src');
|
||||
imageFsImg.alt = '';
|
||||
imageFsImg.style.transform = '';
|
||||
}
|
||||
fsScale = 1;
|
||||
fsTx = 0;
|
||||
fsTy = 0;
|
||||
}
|
||||
|
||||
function resolveMorphTarget(cardId) {
|
||||
if (state.openedFromGallery) {
|
||||
const grid = $('gallery-grid');
|
||||
if (grid) {
|
||||
if (state.galleryOrigin) {
|
||||
const exact = Array.from(grid.querySelectorAll('[data-gallery-origin]'))
|
||||
.find(el => el.dataset.galleryOrigin === state.galleryOrigin);
|
||||
if (exact) return exact;
|
||||
}
|
||||
const byCard = grid.querySelector(`[data-card-id="${cardId}"]`);
|
||||
if (byCard) return byCard;
|
||||
}
|
||||
}
|
||||
|
||||
const board = $('board');
|
||||
if (!board || !cardId) return null;
|
||||
|
||||
@@ -133,7 +282,9 @@ function startCloseMorph(cardId) {
|
||||
|
||||
const frame = document.createElement('div');
|
||||
frame.className = 'detail-close-frame';
|
||||
const targetCard = state.cards.find(c => c.id == target.dataset.id);
|
||||
const targetCard = target.dataset.id
|
||||
? state.cards.find(c => c.id == target.dataset.id)
|
||||
: card;
|
||||
Object.assign(frame.style, {
|
||||
left: `${from.left}px`,
|
||||
top: `${from.top}px`,
|
||||
@@ -170,10 +321,12 @@ function startCloseMorph(cardId) {
|
||||
|
||||
export function closeDetail(options = {}) {
|
||||
const animate = options?.animate !== false;
|
||||
const restoreGallery = options?.restoreGallery !== false;
|
||||
const cardId = state.viewingCard?.id;
|
||||
|
||||
if (animate) startCloseMorph(cardId);
|
||||
else clearMorphGhost();
|
||||
closeImageFullscreen();
|
||||
|
||||
detail.classList.remove('open', 'zen');
|
||||
document.documentElement.classList.remove('detail-zen-open');
|
||||
@@ -185,6 +338,12 @@ export function closeDetail(options = {}) {
|
||||
state.openedFromSearch = false;
|
||||
$('search-input')?.focus();
|
||||
}
|
||||
if (state.openedFromGallery) {
|
||||
const galleryOpen = $('gallery')?.classList.contains('open');
|
||||
state.openedFromGallery = false;
|
||||
state.galleryOrigin = null;
|
||||
if (restoreGallery && !galleryOpen) window._openGallery?.();
|
||||
}
|
||||
}
|
||||
|
||||
export function showDetailCard(id) {
|
||||
@@ -241,6 +400,8 @@ function showContent(card) {
|
||||
$('detail-color').style.background = card.color;
|
||||
titleEl.textContent = card.title;
|
||||
bodyEl.innerHTML = renderMd(card.body);
|
||||
applyReadingResizeHandles(bodyEl);
|
||||
bindDetailImageFullscreen(bodyEl);
|
||||
|
||||
// Interactive checkboxes
|
||||
bodyEl.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => {
|
||||
@@ -280,6 +441,68 @@ function showContent(card) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyReadingResizeHandles(root) {
|
||||
if (!root) return;
|
||||
// Reading aid only: temporary per-block font-size scaling (not persisted).
|
||||
root.querySelectorAll('p, blockquote, pre').forEach(el => {
|
||||
el.classList.add('detail-reading-resize');
|
||||
|
||||
const line = document.createElement('button');
|
||||
line.type = 'button';
|
||||
line.className = 'detail-font-dragline';
|
||||
line.title = 'drag to resize text';
|
||||
line.setAttribute('aria-label', 'resize text');
|
||||
el.appendChild(line);
|
||||
|
||||
line.addEventListener('mousedown', e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const startY = e.clientY;
|
||||
const startFont = parseFloat(getComputedStyle(el).fontSize) || 14;
|
||||
const startLineHeightPx = parseFloat(getComputedStyle(el).lineHeight);
|
||||
const lineRatio = Number.isFinite(startLineHeightPx) && startLineHeightPx > 0
|
||||
? startLineHeightPx / startFont
|
||||
: 1.65;
|
||||
const prevSelect = document.body.style.userSelect;
|
||||
const min = 12;
|
||||
const max = 44;
|
||||
|
||||
el.classList.add('resizing');
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = ev => {
|
||||
const delta = ev.clientY - startY;
|
||||
const next = Math.min(max, Math.max(min, startFont + delta * 0.08));
|
||||
el.style.fontSize = `${next.toFixed(2)}px`;
|
||||
el.style.lineHeight = `${(next * lineRatio).toFixed(2)}px`;
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.style.userSelect = prevSelect;
|
||||
el.classList.remove('resizing');
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindDetailImageFullscreen(root) {
|
||||
if (!root) return;
|
||||
root.querySelectorAll('img').forEach(img => {
|
||||
img.title = 'double-click to open fullscreen';
|
||||
img.addEventListener('dblclick', e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openImageFullscreen(img.getAttribute('src'), img.getAttribute('alt') || 'image');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event listeners ──
|
||||
|
||||
// Delegated card-link clicks (replaces inline onclick)
|
||||
@@ -290,6 +513,14 @@ detail.addEventListener('click', e => {
|
||||
});
|
||||
$('detail-close')?.addEventListener('click', closeDetail);
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (!imageFsOverlay?.classList.contains('open')) return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
closeImageFullscreen();
|
||||
}, true);
|
||||
|
||||
// Zen mode
|
||||
$('detail-zen')?.addEventListener('click', () => {
|
||||
detail.classList.toggle('zen');
|
||||
@@ -303,8 +534,13 @@ $('detail-zen')?.addEventListener('click', () => {
|
||||
$('detail-edit')?.addEventListener('click', () => {
|
||||
if (!state.viewingCard) return;
|
||||
const card = state.viewingCard;
|
||||
const fromGallery = state.openedFromGallery;
|
||||
state.openedFromSearch = false;
|
||||
closeDetail({ animate: false });
|
||||
state.openedFromGallery = false;
|
||||
state.galleryOrigin = null;
|
||||
state.returnToGalleryAfterEdit = fromGallery;
|
||||
closeDetail({ animate: false, restoreGallery: false });
|
||||
$('gallery')?.classList.remove('open');
|
||||
window._closeSearch();
|
||||
window._openModal(card);
|
||||
});
|
||||
@@ -315,7 +551,8 @@ $('detail-delete')?.addEventListener('click', async () => {
|
||||
if (!ok) return;
|
||||
pushCardDelete('delete', snapshotCard(state.viewingCard));
|
||||
await api(`api/cards/${state.viewingCard.id}`, { method: 'DELETE' });
|
||||
closeDetail({ animate: false });
|
||||
state.galleryOrigin = null;
|
||||
closeDetail({ animate: false, restoreGallery: false });
|
||||
await window._reloadAll();
|
||||
});
|
||||
|
||||
|
||||
@@ -59,7 +59,11 @@ export function openModal(card = null) {
|
||||
inpTitle.focus();
|
||||
}
|
||||
|
||||
export function closeModal() { modal.classList.remove('open'); state.editingId = null; }
|
||||
export function closeModal() {
|
||||
modal.classList.remove('open');
|
||||
state.editingId = null;
|
||||
state.returnToGalleryAfterEdit = false;
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
if (state.previewVisible) previewContent.innerHTML = renderMd(body.value);
|
||||
@@ -68,16 +72,26 @@ function updatePreview() {
|
||||
export async function saveCard() {
|
||||
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null };
|
||||
if (!data.title.trim()) return;
|
||||
const returnToGallery = state.returnToGalleryAfterEdit;
|
||||
if (state.editingId) {
|
||||
const prev = snapshotCard(state.cards.find(c => c.id == state.editingId));
|
||||
if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id });
|
||||
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
|
||||
} else {
|
||||
data.position = -1;
|
||||
if (state.activeGroup) {
|
||||
const parent = state.cards.find(c => c.id == state.activeGroup);
|
||||
const groupCards = parent ? [parent, ...state.cards.filter(c => c.parent_id == parent.id)] : [];
|
||||
const maxPos = groupCards.reduce((max, c) => Math.max(max, Number(c.position) || 0), -1);
|
||||
data.parent_id = parent ? parent.id : state.activeGroup;
|
||||
data.position = maxPos + 1;
|
||||
} else {
|
||||
data.position = -1;
|
||||
}
|
||||
await api('api/cards', { method: 'POST', body: data });
|
||||
}
|
||||
closeModal();
|
||||
await window._reloadAll();
|
||||
if (returnToGallery) window._openGallery?.();
|
||||
}
|
||||
|
||||
// ── Markdown toolbar ──
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function openGallery() {
|
||||
if (linked.length) {
|
||||
if (orphaned.length) html += `<div class="col-span-full font-pixel text-[10px] text-t-muted flex items-center gap-1.5 pt-3"><i class="ph ph-link text-xs"></i>linked to cards</div>`;
|
||||
html += linked.map(img => `
|
||||
<div class="gallery-item group relative cursor-pointer" data-card-id="${img.cardId}">
|
||||
<div class="gallery-item group relative cursor-pointer" data-card-id="${img.cardId}" data-gallery-origin="${encodeURIComponent(img.url)}">
|
||||
<div class="aspect-square bg-t-bg border border-t-border rounded overflow-hidden">
|
||||
<img src="${img.url}" class="w-full h-full object-cover" loading="lazy">
|
||||
</div>
|
||||
@@ -94,7 +94,12 @@ export async function openGallery() {
|
||||
|
||||
// Linked image clicks → open card
|
||||
grid.querySelectorAll('[data-card-id]').forEach(item => {
|
||||
item.addEventListener('click', () => { closeGallery(); window._openDetail(item.dataset.cardId); });
|
||||
item.addEventListener('click', () => {
|
||||
window._openDetail(item.dataset.cardId, {
|
||||
fromGallery: true,
|
||||
galleryOrigin: item.dataset.galleryOrigin || null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Orphan: create card from image
|
||||
|
||||
@@ -9,6 +9,9 @@ export const state = {
|
||||
editingId: null,
|
||||
viewingCard: null,
|
||||
openedFromSearch: false,
|
||||
openedFromGallery: false,
|
||||
galleryOrigin: null,
|
||||
returnToGalleryAfterEdit: false,
|
||||
previewVisible: false,
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user