Commit 4553c4a8 authored by Matthias Betz's avatar Matthias Betz
Browse files

open files by clicking the empty view or dropping them on the view



- while nothing is shown, the whole view is a button that opens the file
  dialog ("Open a CityGML file / or drop it here")
- files can be dropped on the view at any time; an overlay shows the drop
  target, a drop replaces the shown file like choosing one
- drops elsewhere on the page are ignored instead of making the browser
  leave the viewer

Co-Authored-By: default avatarClaude Opus 5.5 (1M context) <noreply@anthropic.com>
parent 8550fe4a
......@@ -2,7 +2,8 @@
[Go to published viewer](https://transfer.hft-stuttgart.de/pages/citydoctor/citygml-viewer/)
Simply load a CityGML for the viewer to show the geometry.
Open a CityGML file with the button, by clicking the empty view, or by
dropping the file onto the view (also while another file is shown).
CityGML with a coordinate system not in meters will not work.
......
......@@ -7,15 +7,19 @@ import { mountFileInput } from './ui/fileInput.js';
import { mountLodPanel } from './ui/lodPanel.js';
import { mountStatus } from './ui/status.js';
import { mountViewerHints } from './ui/viewerHints.js';
import { ignoreDropsOutside, mountFileDrop } from './ui/fileDrop.js';
const store = createStore(initialState());
const viewer = createViewer(document.getElementById('viewer'), store);
const viewerArea = document.getElementById('viewer');
const viewer = createViewer(viewerArea, store);
mountStatus(document.getElementById('status'), store, document.getElementById('progress'));
mountLodPanel(document.getElementById('lod-panel'), store);
const fileInput = mountFileInput(document.getElementById('file'), { onFile: open, enabled: viewer.available });
ignoreDropsOutside(window);
if (viewer.available) {
mountViewerHints(document.getElementById('viewer'), store);
mountViewerHints(viewerArea, store, { onOpen: () => fileInput.click() });
mountFileDrop(viewerArea, { onFile: open });
}
mountLodPanel(document.getElementById('lod-panel'), store);
mountFileInput(document.getElementById('file'), { onFile: open, enabled: viewer.available });
let nextLayerId = 1;
let currentLoad = null;
......
......@@ -208,14 +208,60 @@ a {
touch-action: none;
}
/* While nothing is shown, the whole view opens a file. */
.viewer-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
inset: var(--space-4);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-1);
margin: 0;
border: 1px dashed var(--line);
border-radius: var(--radius);
background: transparent;
font: inherit;
color: var(--muted);
cursor: pointer;
}
.viewer-empty:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
.viewer-empty-title {
font-size: var(--text-title);
font-weight: 500;
color: var(--ink);
}
.viewer-empty:hover .viewer-empty-title {
color: var(--accent-strong);
}
.viewer-empty-detail {
font-size: var(--text-small);
}
/* Shown while a file is dragged over the view; the model stays faintly
visible behind it, the empty-state text is hidden so the texts do not overlap. */
.app-viewer.is-dragging .viewer-empty {
visibility: hidden;
}
.viewer-drop {
position: absolute;
inset: var(--space-4);
display: grid;
place-items: center;
border: 2px dashed var(--accent);
border-radius: var(--radius);
background: rgb(227 239 240 / 0.85); /* --accent-soft */
color: var(--accent-strong);
font-size: var(--text-title);
font-weight: 500;
pointer-events: none;
}
......
// Dropping a file on the viewer opens it, at any time. While a file is
// dragged over the viewer an overlay shows where to drop it.
// True if a drag carries files. During dragenter/dragover browsers only
// expose the types, not the files themselves.
export function hasFiles(dataTransfer) {
return !!dataTransfer && Array.from(dataTransfer.types || []).includes('Files');
}
// The file to open from a drop: the first one, or null.
export function droppedFile(dataTransfer) {
return dataTransfer && dataTransfer.files && dataTransfer.files.length > 0 ? dataTransfer.files[0] : null;
}
// Browsers fire dragenter on each child element before dragleave on the one
// that was left, so the drag is inside until every enter has its leave.
// Each call returns whether the drag is inside afterwards.
export function createDragDepth() {
let depth = 0;
return {
enter() {
depth++;
return depth > 0;
},
leave() {
depth = Math.max(0, depth - 1);
return depth > 0;
},
reset() {
depth = 0;
return false;
},
};
}
export function mountFileDrop(target, { onFile }) {
const overlay = document.createElement('div');
overlay.className = 'viewer-drop';
overlay.textContent = 'Drop to open';
overlay.hidden = true;
target.append(overlay);
const depth = createDragDepth();
function show(inside) {
overlay.hidden = !inside;
target.classList.toggle('is-dragging', inside);
}
target.addEventListener('dragenter', event => {
if (hasFiles(event.dataTransfer)) {
event.preventDefault();
show(depth.enter());
}
});
target.addEventListener('dragover', event => {
if (hasFiles(event.dataTransfer)) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}
});
target.addEventListener('dragleave', event => {
if (hasFiles(event.dataTransfer)) {
show(depth.leave());
}
});
target.addEventListener('drop', event => {
event.preventDefault();
show(depth.reset());
const file = droppedFile(event.dataTransfer);
if (file !== null) {
onFile(file);
}
});
}
// A file dropped elsewhere on the page would make the browser leave the
// viewer and show the file instead; such drops are ignored. Drops handled
// by the viewer have already called preventDefault and keep their effect.
export function ignoreDropsOutside(win) {
win.addEventListener('dragover', event => {
if (hasFiles(event.dataTransfer) && !event.defaultPrevented) {
event.preventDefault();
event.dataTransfer.dropEffect = 'none';
}
});
win.addEventListener('drop', event => {
if (hasFiles(event.dataTransfer)) {
event.preventDefault();
}
});
}
// Text on top of the 3D view: an invitation to open a file while nothing is
// shown, and the mouse controls once there is geometry to look at.
// Text on top of the 3D view: while nothing is shown, the whole view is a
// button that opens a file (onOpen); once there is geometry, the mouse
// controls are shown.
// { empty, controls }: which of the two hints to show.
export function viewerHints(state) {
......@@ -8,10 +9,18 @@ export function viewerHints(state) {
return { empty: !hasGeometry && !loading, controls: hasGeometry };
}
export function mountViewerHints(container, store) {
const empty = document.createElement('p');
export function mountViewerHints(container, store, { onOpen }) {
const empty = document.createElement('button');
empty.type = 'button';
empty.className = 'viewer-empty';
empty.textContent = 'Open a CityGML file to start';
const title = document.createElement('span');
title.className = 'viewer-empty-title';
title.textContent = 'Open a CityGML file';
const detail = document.createElement('span');
detail.className = 'viewer-empty-detail';
detail.textContent = 'or drop it here';
empty.append(title, detail);
empty.addEventListener('click', onOpen);
const controls = document.createElement('ul');
controls.className = 'viewer-controls';
......
import test from 'node:test';
import assert from 'node:assert/strict';
import { droppedFile, hasFiles, createDragDepth } from '../public/src/ui/fileDrop.js';
// Minimal stand-ins for DataTransfer as browsers fill it on drop / dragenter.
const fileA = { name: 'a.gml' };
const fileB = { name: 'b.gml' };
const transfer = (files, types = files.length ? ['Files'] : ['text/plain']) => ({ files, types });
test('the first dropped file is taken', () => {
assert.equal(droppedFile(transfer([fileA, fileB])), fileA);
});
test('a drop without files gives nothing', () => {
assert.equal(droppedFile(transfer([])), null);
assert.equal(droppedFile(null), null);
});
test('only drags that carry files are accepted', () => {
// During dragenter/dragover browsers expose only the types, not the files.
assert.equal(hasFiles({ types: ['Files'], files: [] }), true);
assert.equal(hasFiles({ types: ['text/plain', 'text/uri-list'], files: [] }), false);
assert.equal(hasFiles(null), false);
});
test('the drag counts as inside until every enter has its leave', () => {
// Moving over child elements fires enter on the child before leave on the parent.
const depth = createDragDepth();
assert.equal(depth.enter(), true);
assert.equal(depth.enter(), true);
assert.equal(depth.leave(), true);
assert.equal(depth.leave(), false);
});
test('a drop ends the drag even without leave events', () => {
const depth = createDragDepth();
depth.enter();
depth.enter();
assert.equal(depth.reset(), false);
assert.equal(depth.leave(), false, 'stays outside after reset');
});
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment