feat(voyage): add drag-drop with webkitGetAsEntry + Firefox 150 Win guard

This commit is contained in:
Kjell Tore Guttormsen 2026-05-10 16:18:23 +02:00
commit 974835537a

View file

@ -76,6 +76,40 @@
outline-offset: 2px;
}
/* v4.3 Step 12 — drag-drop overlay (hidden until dragenter). */
.voyage-dropzone {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.voyage-dropzone[hidden] { display: none; }
.voyage-dropzone--active {
pointer-events: auto;
}
.voyage-dropzone__inner {
background: var(--color-surface);
color: var(--color-text-primary);
border: 2px dashed var(--color-primary-500);
border-radius: var(--radius-md);
padding: var(--space-6) var(--space-8, 32px);
text-align: center;
max-width: 520px;
}
.voyage-dropzone__title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--space-2);
}
.voyage-dropzone__hint {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
/* Render-pipeline layout (left 70% viewport / right 30% sidebar reserved
for Step 10). The viewport panel mounts the rendered markdown. */
.voyage-layout {
@ -518,6 +552,23 @@
aria-hidden="true"
tabindex="-1"
/>
<!-- v4.3 Step 12 — drag-drop overlay. Hidden by default; shown via JS on
document-level dragenter. Origin-disclosure tooltip warns operators
that Chromium taints drags from non-OS sources. -->
<div
class="voyage-dropzone"
data-drop-target
role="region"
aria-label="Slipp prosjektmappe her"
aria-hidden="true"
hidden
>
<div class="voyage-dropzone__inner">
<div class="voyage-dropzone__title">Slipp prosjektmappe her</div>
<div class="voyage-dropzone__hint">Dra direkte fra OS-filutforsker for korrekt path-info.</div>
</div>
</div>
<main id="main-content">
<section
class="guide-panel guide-panel--info"
@ -913,6 +964,138 @@ playground first-run shows a complete round-trip-able artifact.
});
}
// ---- v4.3 Step 12 — drag-drop with webkitGetAsEntry ---------------
// Document-level dragenter shows the overlay; drop iterates
// dataTransfer.items, walks DirectoryEntry.readEntries() recursively
// (Chromium 100-entry-cap → loop until empty array), converts to
// File[] with synthetic webkitRelativePath, then forwards to
// loadProjectDirectory (Step 13). Firefox 150 on Windows triggers a
// warn-panel and returns early due to the upstream crash bug.
function wireDragDrop() {
var dropzone = document.querySelector('[data-drop-target]');
if (!dropzone) return;
var ua = navigator.userAgent || '';
var platform = navigator.platform || '';
var isFirefox150Windows = /Firefox\/150/.test(ua) && /Win/.test(platform);
function showWarnPanel(msg) {
var warn = document.createElement('section');
warn.className = 'guide-panel guide-panel--warn';
warn.setAttribute('role', 'status');
warn.innerHTML =
'<div class="guide-panel__title">Drag-drop deaktivert</div>' +
'<div class="guide-panel__body">' + escapeHtml(msg) + '</div>';
var main = document.getElementById('main-content');
if (main) main.insertBefore(warn, main.firstChild);
}
function showOverlay() {
dropzone.hidden = false;
dropzone.classList.add('voyage-dropzone--active');
dropzone.setAttribute('aria-hidden', 'false');
}
function hideOverlay() {
dropzone.hidden = true;
dropzone.classList.remove('voyage-dropzone--active');
dropzone.setAttribute('aria-hidden', 'true');
}
document.addEventListener('dragenter', function (e) {
if (e.dataTransfer && Array.from(e.dataTransfer.types || []).indexOf('Files') !== -1) {
showOverlay();
}
});
dropzone.addEventListener('dragover', function (e) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
});
dropzone.addEventListener('dragleave', function (e) {
// Only hide when leaving the overlay entirely (relatedTarget outside).
if (!e.relatedTarget || e.relatedTarget === document.documentElement) {
hideOverlay();
}
});
dropzone.addEventListener('drop', function (e) {
e.preventDefault();
hideOverlay();
if (isFirefox150Windows) {
showWarnPanel('Drag-drop deaktivert i Firefox 150 på Windows pga kjent crash-bug. Bruk knappen «Velg prosjektmappe» i stedet.');
return;
}
var items = e.dataTransfer && e.dataTransfer.items;
if (!items || !items.length) return;
var collected = [];
var basePath = '';
var pending = 0;
var allDone = false;
function maybeFinish() {
if (allDone && pending === 0) {
if (typeof loadProjectDirectory === 'function') {
loadProjectDirectory(collected, basePath);
} else {
try { console.log('[voyage] drag-drop: ' + collected.length + ' files in ' + basePath); } catch (_) {}
}
}
}
// Recursive walker — readEntries() returns max 100 entries per call,
// so loop until it returns an empty array (Chromium spec).
function walkEntry(entry, prefix) {
if (!entry) return;
if (entry.isFile) {
pending++;
entry.file(function (file) {
try {
Object.defineProperty(file, 'webkitRelativePath', {
value: prefix + entry.name,
configurable: true
});
} catch (_) { /* property may already exist */ }
collected.push(file);
pending--;
maybeFinish();
}, function () {
pending--;
maybeFinish();
});
} else if (entry.isDirectory) {
if (!basePath) basePath = entry.name;
var reader = entry.createReader();
function readBatch() {
pending++;
reader.readEntries(function (entries) {
pending--;
if (!entries.length) {
maybeFinish();
return;
}
for (var i = 0; i < entries.length; i++) {
walkEntry(entries[i], prefix + entry.name + '/');
}
readBatch();
}, function () {
pending--;
maybeFinish();
});
}
readBatch();
}
}
for (var i = 0; i < items.length; i++) {
var item = items[i];
var entry = item.webkitGetAsEntry && item.webkitGetAsEntry();
if (entry) walkEntry(entry, '');
}
allDone = true;
maybeFinish();
});
}
// ---- v4.3 Step 9 — renderPageShell --------------------------------
// Universal page-header for dashboard + artifact-detail flater.
// Returns an HTML string wrapping body content with DS Tier 3
@ -1650,6 +1833,10 @@ playground first-run shows a complete round-trip-able artifact.
// Step 11 (v4.3) — webkitdirectory project-loader wiring (button +
// hidden file-input + browser-support detection).
wireProjectLoader();
// Step 12 (v4.3) — drag-drop overlay with webkitGetAsEntry recursive
// walker + Firefox 150 Windows UA-guard.
wireDragDrop();
}
function setThemeLabel(theme) {