drag drop update

This commit is contained in:
Troy Grunt
2026-02-11 22:30:55 +01:00
parent c31e1a308d
commit fb4ee93b17
8 changed files with 977 additions and 188 deletions

View File

@@ -0,0 +1,471 @@
(() => {
const SVG_NS = 'http://www.w3.org/2000/svg';
const VIEWBOX_WIDTH = 2000;
const VIEWBOX_HEIGHT = 1000;
const SNAP_TOLERANCE = 12;
function initFloorSvgEditor() {
const editor = document.getElementById('floor-svg-editor');
const svg = document.getElementById('floor-svg-canvas');
const hiddenInput = document.getElementById('floor-svg-content');
if (!editor || !svg || !hiddenInput) {
return;
}
const controls = {
startPolyline: document.getElementById('floor-start-polyline'),
finishPolyline: document.getElementById('floor-finish-polyline'),
deletePolyline: document.getElementById('floor-delete-polyline'),
clearDrawing: document.getElementById('floor-clear-drawing'),
lock45: document.getElementById('floor-lock-45'),
snapGuides: document.getElementById('floor-snap-guides'),
addGuide: document.getElementById('floor-add-guide'),
guideOrientation: document.getElementById('floor-guide-orientation'),
guidePosition: document.getElementById('floor-guide-position'),
guideList: document.getElementById('floor-guide-list')
};
const state = {
polylines: [],
guides: [],
selectedPolylineId: null,
activePolylineId: null,
draggingVertex: null
};
loadFromExistingSvg(hiddenInput.value, state);
bindControlEvents(controls, state, svg, hiddenInput);
bindCanvasEvents(svg, controls, state, hiddenInput);
render(svg, controls, state, hiddenInput);
}
function bindControlEvents(controls, state, svg, hiddenInput) {
controls.startPolyline.addEventListener('click', () => {
const id = createId('poly');
state.polylines.push({
id,
points: []
});
state.activePolylineId = id;
state.selectedPolylineId = id;
render(svg, controls, state, hiddenInput);
});
controls.finishPolyline.addEventListener('click', () => {
finishActivePolyline(state);
render(svg, controls, state, hiddenInput);
});
controls.deletePolyline.addEventListener('click', () => {
if (!state.selectedPolylineId) {
return;
}
state.polylines = state.polylines.filter((line) => line.id !== state.selectedPolylineId);
if (state.activePolylineId === state.selectedPolylineId) {
state.activePolylineId = null;
}
state.selectedPolylineId = null;
render(svg, controls, state, hiddenInput);
});
controls.clearDrawing.addEventListener('click', () => {
state.polylines = [];
state.guides = [];
state.selectedPolylineId = null;
state.activePolylineId = null;
state.draggingVertex = null;
render(svg, controls, state, hiddenInput);
});
controls.addGuide.addEventListener('click', () => {
const orientation = controls.guideOrientation.value === 'horizontal' ? 'horizontal' : 'vertical';
const position = Number(controls.guidePosition.value);
if (!Number.isFinite(position)) {
return;
}
state.guides.push({
id: createId('guide'),
orientation,
position: Math.round(position)
});
controls.guidePosition.value = '';
render(svg, controls, state, hiddenInput);
});
controls.guideList.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const id = target.getAttribute('data-remove-guide');
if (!id) {
return;
}
state.guides = state.guides.filter((guide) => guide.id !== id);
render(svg, controls, state, hiddenInput);
});
}
function bindCanvasEvents(svg, controls, state, hiddenInput) {
svg.addEventListener('pointerdown', (event) => {
const target = event.target;
if (!(target instanceof SVGElement)) {
return;
}
const vertex = target.closest('[data-vertex-index]');
if (vertex) {
const polylineId = vertex.getAttribute('data-polyline-id');
const vertexIndex = Number(vertex.getAttribute('data-vertex-index'));
if (polylineId && Number.isInteger(vertexIndex)) {
state.selectedPolylineId = polylineId;
state.draggingVertex = { polylineId, vertexIndex };
vertex.setPointerCapture(event.pointerId);
}
return;
}
const polylineEl = target.closest('[data-polyline-id]');
if (polylineEl) {
const polylineId = polylineEl.getAttribute('data-polyline-id');
if (polylineId) {
state.selectedPolylineId = polylineId;
if (!state.activePolylineId) {
state.activePolylineId = polylineId;
}
render(svg, controls, state, hiddenInput);
}
return;
}
const point = toSvgPoint(svg, event);
if (!point) {
return;
}
if (!state.activePolylineId) {
const id = createId('poly');
state.polylines.push({ id, points: [] });
state.activePolylineId = id;
state.selectedPolylineId = id;
}
const activeLine = state.polylines.find((line) => line.id === state.activePolylineId);
if (!activeLine) {
return;
}
let nextPoint = point;
if (controls.lock45.checked && activeLine.points.length > 0) {
nextPoint = lockTo45(activeLine.points[activeLine.points.length - 1], nextPoint);
}
if (controls.snapGuides.checked) {
nextPoint = snapPointToGuides(nextPoint, state.guides, SNAP_TOLERANCE);
}
activeLine.points.push({
x: Math.round(nextPoint.x),
y: Math.round(nextPoint.y)
});
render(svg, controls, state, hiddenInput);
});
svg.addEventListener('pointermove', (event) => {
if (!state.draggingVertex) {
return;
}
const point = toSvgPoint(svg, event);
if (!point) {
return;
}
const line = state.polylines.find((item) => item.id === state.draggingVertex.polylineId);
if (!line) {
return;
}
const index = state.draggingVertex.vertexIndex;
if (!line.points[index]) {
return;
}
let nextPoint = point;
if (controls.lock45.checked && index > 0 && line.points[index - 1]) {
nextPoint = lockTo45(line.points[index - 1], nextPoint);
}
if (controls.snapGuides.checked) {
nextPoint = snapPointToGuides(nextPoint, state.guides, SNAP_TOLERANCE);
}
line.points[index] = {
x: Math.round(nextPoint.x),
y: Math.round(nextPoint.y)
};
render(svg, controls, state, hiddenInput);
});
svg.addEventListener('pointerup', () => {
state.draggingVertex = null;
});
svg.addEventListener('pointercancel', () => {
state.draggingVertex = null;
});
}
function render(svg, controls, state, hiddenInput) {
const selected = state.polylines.find((line) => line.id === state.selectedPolylineId) || null;
svg.innerHTML = '';
svg.setAttribute('viewBox', `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`);
const background = createSvgElement('rect');
background.setAttribute('x', '0');
background.setAttribute('y', '0');
background.setAttribute('width', String(VIEWBOX_WIDTH));
background.setAttribute('height', String(VIEWBOX_HEIGHT));
background.setAttribute('fill', '#fafafa');
background.setAttribute('stroke', '#e1e1e1');
background.setAttribute('stroke-width', '1');
svg.appendChild(background);
state.guides.forEach((guide) => {
const line = createSvgElement('line');
if (guide.orientation === 'horizontal') {
line.setAttribute('x1', '0');
line.setAttribute('y1', String(guide.position));
line.setAttribute('x2', String(VIEWBOX_WIDTH));
line.setAttribute('y2', String(guide.position));
} else {
line.setAttribute('x1', String(guide.position));
line.setAttribute('y1', '0');
line.setAttribute('x2', String(guide.position));
line.setAttribute('y2', String(VIEWBOX_HEIGHT));
}
line.setAttribute('stroke', '#8f8f8f');
line.setAttribute('stroke-width', '1');
line.setAttribute('stroke-dasharray', '8 6');
line.setAttribute('opacity', '0.8');
line.setAttribute('data-guide-id', guide.id);
svg.appendChild(line);
});
state.polylines.forEach((polyline) => {
const line = createSvgElement('polyline');
line.setAttribute('fill', 'none');
line.setAttribute('stroke', polyline.id === state.selectedPolylineId ? '#007bff' : '#1f2937');
line.setAttribute('stroke-width', polyline.id === state.selectedPolylineId ? '5' : '3');
line.setAttribute('points', polyline.points.map((point) => `${point.x},${point.y}`).join(' '));
line.setAttribute('data-polyline-id', polyline.id);
svg.appendChild(line);
});
if (selected) {
selected.points.forEach((point, index) => {
const vertex = createSvgElement('circle');
vertex.setAttribute('cx', String(point.x));
vertex.setAttribute('cy', String(point.y));
vertex.setAttribute('r', '8');
vertex.setAttribute('fill', '#ffffff');
vertex.setAttribute('stroke', '#dc3545');
vertex.setAttribute('stroke-width', '3');
vertex.setAttribute('data-polyline-id', selected.id);
vertex.setAttribute('data-vertex-index', String(index));
svg.appendChild(vertex);
});
}
controls.guideList.innerHTML = '';
state.guides.forEach((guide) => {
const li = document.createElement('li');
const label = guide.orientation === 'horizontal' ? 'Horizontal' : 'Vertikal';
li.innerHTML = `
<span>${label}: ${Math.round(guide.position)}</span>
<button type="button" class="button button-danger" data-remove-guide="${guide.id}">X</button>
`;
controls.guideList.appendChild(li);
});
hiddenInput.value = buildSvgMarkup(state.polylines, state.guides);
}
function finishActivePolyline(state) {
const active = state.polylines.find((line) => line.id === state.activePolylineId);
if (active && active.points.length < 2) {
state.polylines = state.polylines.filter((line) => line.id !== active.id);
if (state.selectedPolylineId === active.id) {
state.selectedPolylineId = null;
}
}
state.activePolylineId = null;
}
function loadFromExistingSvg(raw, state) {
const content = String(raw || '').trim();
if (!content) {
return;
}
try {
const parser = new DOMParser();
const doc = parser.parseFromString(content, 'image/svg+xml');
const root = doc.documentElement;
if (!root || root.nodeName.toLowerCase() === 'parsererror') {
return;
}
root.querySelectorAll('line[data-guide="1"], line.floor-guide').forEach((line) => {
const orientation = line.getAttribute('data-orientation') === 'horizontal' ? 'horizontal' : 'vertical';
const position = orientation === 'horizontal'
? Number(line.getAttribute('y1'))
: Number(line.getAttribute('x1'));
if (Number.isFinite(position)) {
state.guides.push({
id: createId('guide'),
orientation,
position: Math.round(position)
});
}
});
root.querySelectorAll('polyline').forEach((polyline) => {
const pointsAttr = polyline.getAttribute('points') || '';
const points = parsePoints(pointsAttr);
if (points.length < 2) {
return;
}
state.polylines.push({
id: createId('poly'),
points
});
});
if (state.polylines.length > 0) {
state.selectedPolylineId = state.polylines[0].id;
}
} catch (error) {
// ignore invalid svg content
}
}
function parsePoints(pointsAttr) {
return pointsAttr
.trim()
.split(/\s+/)
.map((pair) => {
const [x, y] = pair.split(',').map((value) => Number(value));
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return null;
}
return { x: Math.round(x), y: Math.round(y) };
})
.filter(Boolean);
}
function buildSvgMarkup(polylines, guides) {
const svg = createSvgElement('svg');
svg.setAttribute('xmlns', SVG_NS);
svg.setAttribute('viewBox', `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`);
const background = createSvgElement('rect');
background.setAttribute('x', '0');
background.setAttribute('y', '0');
background.setAttribute('width', String(VIEWBOX_WIDTH));
background.setAttribute('height', String(VIEWBOX_HEIGHT));
background.setAttribute('fill', '#fafafa');
background.setAttribute('stroke', '#e1e1e1');
background.setAttribute('stroke-width', '1');
svg.appendChild(background);
guides.forEach((guide) => {
const line = createSvgElement('line');
if (guide.orientation === 'horizontal') {
line.setAttribute('x1', '0');
line.setAttribute('y1', String(guide.position));
line.setAttribute('x2', String(VIEWBOX_WIDTH));
line.setAttribute('y2', String(guide.position));
} else {
line.setAttribute('x1', String(guide.position));
line.setAttribute('y1', '0');
line.setAttribute('x2', String(guide.position));
line.setAttribute('y2', String(VIEWBOX_HEIGHT));
}
line.setAttribute('stroke', '#8f8f8f');
line.setAttribute('stroke-width', '1');
line.setAttribute('stroke-dasharray', '8 6');
line.setAttribute('class', 'floor-guide');
line.setAttribute('data-guide', '1');
line.setAttribute('data-orientation', guide.orientation);
svg.appendChild(line);
});
polylines.forEach((polyline) => {
if (polyline.points.length < 2) {
return;
}
const line = createSvgElement('polyline');
line.setAttribute('fill', 'none');
line.setAttribute('stroke', '#1f2937');
line.setAttribute('stroke-width', '3');
line.setAttribute('class', 'floor-polyline');
line.setAttribute('points', polyline.points.map((point) => `${point.x},${point.y}`).join(' '));
svg.appendChild(line);
});
return new XMLSerializer().serializeToString(svg);
}
function lockTo45(origin, point) {
const dx = point.x - origin.x;
const dy = point.y - origin.y;
const length = Math.sqrt((dx * dx) + (dy * dy));
if (length === 0) {
return { x: origin.x, y: origin.y };
}
const angle = Math.atan2(dy, dx);
const step = Math.PI / 4;
const snapped = Math.round(angle / step) * step;
return {
x: origin.x + Math.cos(snapped) * length,
y: origin.y + Math.sin(snapped) * length
};
}
function snapPointToGuides(point, guides, tolerance) {
let next = { ...point };
guides.forEach((guide) => {
if (guide.orientation === 'vertical') {
if (Math.abs(next.x - guide.position) <= tolerance) {
next.x = guide.position;
}
} else if (Math.abs(next.y - guide.position) <= tolerance) {
next.y = guide.position;
}
});
return next;
}
function toSvgPoint(svg, event) {
const pt = svg.createSVGPoint();
pt.x = event.clientX;
pt.y = event.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) {
return null;
}
const transformed = pt.matrixTransform(ctm.inverse());
return {
x: transformed.x,
y: transformed.y
};
}
function createSvgElement(name) {
return document.createElementNS(SVG_NS, name);
}
function createId(prefix) {
return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
}
document.addEventListener('DOMContentLoaded', initFloorSvgEditor);
})();