/
diloytd
/
rectangle
Обзор
Документация
Войти
/
diloytd
/
rectangle
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/app.js
553 строки
20 KB
diloytd
add second window for pictures
10 июл 2026, 13:43
10 июл 2026, 13:43
dc3e280
Код
Авторство
О чём код?
import { SvgDrawing } from "./drawing.js?v=20260710-1340"; import { createExportSvgMarkup } from "./export-svg.js?v=20260710-1340"; import { createRootRectangle, getRectangleById, getResizeCoordinateBounds, replaceRectangleWithChildren, RESIZE_EDGE_TYPES, resizeRectanglesByEdge, splitRectangle, validateSplitDistance, validateRootSize, } from "./geometry.js?v=20260710-1340"; import { HistoryStack } from "./history.js?v=20260710-1340"; import { SplitModal } from "./modal.js?v=20260710-1340"; import { AppUI } from "./ui.js?v=20260710-1340"; import { clonePlain } from "./utils.js?v=20260710-1340"; const PROJECT_EXPORT_VERSION = 1; const DOWNLOAD_FILE_PREFIX = "rectangle-project"; /** * Builds a filesystem-friendly timestamp for downloaded files. * @returns {string} Current local timestamp without characters that Windows rejects in file names. */ const createDownloadTimestamp = () => new Date().toISOString().replace(/[:.]/g, "-"); /** * Coordinates UI, geometry, drawing and history modules. * Application state is kept here so feature modules remain reusable. */ class RectangleEditorApp { /** * Creates the rectangle editor application. * @returns {RectangleEditorApp} Application instance. */ constructor() { this.state = this.createEmptyState(); this.resizeSnapshot = null; this.history = new HistoryStack(); this.ui = new AppUI({ onCreate: (size) => this.handleCreate(size), onToolChange: (tool) => this.handleToolChange(tool), onUndo: () => this.handleUndo(), onRedo: () => this.handleRedo(), onSaveProject: () => this.handleSaveProject(), onExportSvg: () => this.handleExportSvg(), onPrintPdf: () => this.handlePrintPdf(), onClear: () => this.handleClear(), }); this.modal = new SplitModal({ modal: document.querySelector("#split-modal"), form: document.querySelector("#split-form"), title: document.querySelector("#modal-title"), description: document.querySelector("#modal-description"), range: document.querySelector("#modal-range"), input: document.querySelector("#split-distance"), inputLabel: document.querySelector("#split-distance-label"), remainderInput: document.querySelector("#split-remainder"), remainderLabel: document.querySelector("#split-remainder-label"), error: document.querySelector("#modal-error"), }); this.drawing = new SvgDrawing({ host: document.querySelector("#drawing-root"), onRectangleSelect: (rectangleId, distance) => this.handleRectangleSelect(rectangleId, distance), onResizeStart: (edge) => this.handleRectangleResizeStart(edge), onResizeMove: (edge, coordinate) => this.handleRectangleResizeMove(edge, coordinate), onResizeEnd: (edge) => this.handleRectangleResizeEnd(edge), }); this.handleGlobalKeyDown = this.handleGlobalKeyDown.bind(this); window.addEventListener("keydown", this.handleGlobalKeyDown); window.addEventListener("resize", () => this.render()); this.render(); } /** * Creates the default serializable application state. * @returns {{rootWidth: number, rootHeight: number, rectangles: Array<object>, selectedId: string | null, activeTool: string | null}} Empty state. */ createEmptyState() { return { rootWidth: 0, rootHeight: 0, rectangles: [], selectedId: null, activeTool: null, }; } /** * Handles creation of a new outer rectangle. * @param {{width: string, height: string}} size - Raw size inputs. * @returns {void} */ handleCreate(size) { const validation = validateRootSize(size.width, size.height); if (!validation.isValid) { this.ui.showMessage(validation.message); return; } const rootRectangle = createRootRectangle(validation.width, validation.height); this.history.clear(); this.state = { rootWidth: validation.width, rootHeight: validation.height, rectangles: [rootRectangle], selectedId: rootRectangle.id, activeTool: null, }; this.ui.showMessage("Внешний прямоугольник создан."); this.render(); } /** * Selects or toggles the active split tool. * @param {string} tool - Tool id: vertical or horizontal. * @returns {void} */ handleToolChange(tool) { this.state.activeTool = this.state.activeTool === tool ? null : tool; this.render(); } /** * Selects a rectangle and starts split flow when a tool is active. * Mouse input pre-fills exact split values; keyboard activation opens the same modal centered by default. * @param {string} rectangleId - Clicked rectangle id. * @param {number | null} pointerDistance - Distance from pointer position in millimeters, or null for keyboard flow. * @returns {Promise<void>} Resolves after optional modal flow is complete. */ async handleRectangleSelect(rectangleId, pointerDistance = null) { this.state.selectedId = rectangleId; this.render(); if (!this.state.activeTool) { return; } const rectangle = getRectangleById(this.state.rectangles, rectangleId); if (!rectangle) { this.ui.showMessage("Выбранный прямоугольник не найден."); return; } const splitType = this.state.activeTool; const distance = await this.modal.open({ rectangle, splitType, initialDistance: pointerDistance, }); if (distance === null) { return; } this.applySplit(rectangle, splitType, distance); } /** * Applies a validated split and stores the action in history. * @param {{id: string, x: number, y: number, width: number, height: number}} rectangle - Rectangle to split. * @param {string} splitType - Split type: vertical or horizontal. * @param {number} distance - Distance from left or top edge in millimeters. * @returns {void} */ applySplit(rectangle, splitType, distance) { const validation = validateSplitDistance(rectangle, splitType, distance); if (!validation.isValid) { this.ui.showMessage(validation.message); return; } this.history.push(this.getSnapshot()); const children = splitRectangle(rectangle, splitType, validation.value); this.state.rectangles = replaceRectangleWithChildren(this.state.rectangles, rectangle.id, children); this.state.selectedId = children[0].id; this.state.activeTool = null; this.ui.showMessage("Прямоугольник разделён."); this.render(); } /** * Stores the pre-resize state so a drag can be previewed live and committed as one undo step. * @returns {void} */ handleRectangleResizeStart() { this.resizeSnapshot = this.getSnapshot(); this.state.activeTool = null; } /** * Applies a live resize preview from the original drag snapshot. * Recomputing from the snapshot avoids compounding rounding changes during pointer movement. * @param {{type: string, beforeIds: Array<string>, afterIds: Array<string>}} edge - Dragged internal edge. * @param {number} coordinate - Requested edge coordinate in millimeters. * @returns {void} */ handleRectangleResizeMove(edge, coordinate) { if (!this.resizeSnapshot) { return; } this.state = { ...clonePlain(this.resizeSnapshot), activeTool: null, rectangles: resizeRectanglesByEdge(this.resizeSnapshot.rectangles, edge, coordinate), }; this.render(); } /** * Opens exact size confirmation after a completed resize drag and commits the resulting geometry. * Cancelling the modal keeps the mouse-dragged geometry, so the drag is never lost accidentally. * @param {{type: string, beforeIds: Array<string>, afterIds: Array<string>} | null} edge - Resized internal edge. * @returns {Promise<void>} Resolves after optional exact-size confirmation is complete. */ async handleRectangleResizeEnd(edge = null) { if (!this.resizeSnapshot) { return; } const resizeSnapshot = this.resizeSnapshot; const draggedState = this.getSnapshot(); const didChange = JSON.stringify(resizeSnapshot.rectangles) !== JSON.stringify(this.state.rectangles); this.resizeSnapshot = null; if (!didChange) { this.state = resizeSnapshot; this.render(); return; } if (!edge) { this.commitResizeChange(resizeSnapshot); return; } const resizeModalRequest = this.createResizeModalRequest(edge, draggedState.rectangles, resizeSnapshot.rectangles); if (!resizeModalRequest) { this.commitResizeChange(resizeSnapshot); return; } const distance = await this.modal.open(resizeModalRequest.modalOptions); if (distance !== null) { this.state = { ...clonePlain(resizeSnapshot), activeTool: null, rectangles: resizeRectanglesByEdge(resizeSnapshot.rectangles, edge, resizeModalRequest.coordinateStart + distance), }; } this.commitResizeChange(resizeSnapshot); } /** * Stores a resize operation in history and refreshes the UI. * @param {{rootWidth: number, rootHeight: number, rectangles: Array<object>, selectedId: string | null, activeTool: string | null}} resizeSnapshot - State before resizing began. * @returns {void} */ commitResizeChange(resizeSnapshot) { const didChange = JSON.stringify(resizeSnapshot.rectangles) !== JSON.stringify(this.state.rectangles); if (!didChange) { this.state = resizeSnapshot; this.render(); return; } this.history.push(resizeSnapshot); this.ui.showMessage("Размеры прямоугольников изменены."); this.render(); } /** * Builds modal options for confirming exact dimensions around a resized internal border. * @param {{type: string, beforeIds: Array<string>, afterIds: Array<string>}} edge - Resized internal edge. * @param {Array<{id: string, x: number, y: number, width: number, height: number}>} draggedRectangles - Rectangles after mouse drag preview. * @param {Array<{id: string, x: number, y: number, width: number, height: number}>} sourceRectangles - Rectangles before resize, used for legal resize bounds. * @returns {{coordinateStart: number, modalOptions: {rectangle: {width: number, height: number}, splitType: string, initialDistance: number, minDistance: number, maxDistance: number, title: string, description: string, inputLabel: string, remainderLabel: string}} | null} Modal request data or null when adjacent rectangles cannot be resolved. */ createResizeModalRequest(edge, draggedRectangles, sourceRectangles) { const beforeRectangles = draggedRectangles.filter((rectangle) => edge.beforeIds.includes(rectangle.id)); const afterRectangles = draggedRectangles.filter((rectangle) => edge.afterIds.includes(rectangle.id)); if (beforeRectangles.length === 0 || afterRectangles.length === 0) { return null; } if (edge.type === RESIZE_EDGE_TYPES.VERTICAL) { return this.createVerticalResizeModalRequest(edge, beforeRectangles, afterRectangles, sourceRectangles); } return this.createHorizontalResizeModalRequest(edge, beforeRectangles, afterRectangles, sourceRectangles); } /** * Builds modal options for a vertical border, using left and right widths. * @param {{type: string, beforeIds: Array<string>, afterIds: Array<string>}} edge - Resized vertical edge. * @param {Array<{x: number, width: number}>} beforeRectangles - Rectangles on the left side after drag. * @param {Array<{x: number, width: number}>} afterRectangles - Rectangles on the right side after drag. * @param {Array<{id: string, x: number, y: number, width: number, height: number}>} sourceRectangles - Rectangles before resize. * @returns {{coordinateStart: number, modalOptions: {rectangle: {width: number, height: number}, splitType: string, initialDistance: number, minDistance: number, maxDistance: number, title: string, description: string, inputLabel: string, remainderLabel: string}}} Vertical resize modal request. */ createVerticalResizeModalRequest(edge, beforeRectangles, afterRectangles, sourceRectangles) { const bounds = getResizeCoordinateBounds(sourceRectangles, edge); const coordinate = afterRectangles[0].x; const coordinateStart = Math.min(...beforeRectangles.map((rectangle) => rectangle.x)); const coordinateEnd = Math.max(...afterRectangles.map((rectangle) => rectangle.x + rectangle.width)); return { coordinateStart, modalOptions: { rectangle: { width: coordinateEnd - coordinateStart, height: 1, }, splitType: RESIZE_EDGE_TYPES.VERTICAL, initialDistance: coordinate - coordinateStart, minDistance: bounds.min - coordinateStart, maxDistance: bounds.max - coordinateStart, title: "Вертикальная граница", description: "Уточните точные ширины двух соседних частей. Можно изменить любое поле, второе пересчитается автоматически.", inputLabel: "Левая ширина", remainderLabel: "Правая ширина", }, }; } /** * Builds modal options for a horizontal border, using top and bottom heights. * @param {{type: string, beforeIds: Array<string>, afterIds: Array<string>}} edge - Resized horizontal edge. * @param {Array<{y: number, height: number}>} beforeRectangles - Rectangles above the border after drag. * @param {Array<{y: number, height: number}>} afterRectangles - Rectangles below the border after drag. * @param {Array<{id: string, x: number, y: number, width: number, height: number}>} sourceRectangles - Rectangles before resize. * @returns {{coordinateStart: number, modalOptions: {rectangle: {width: number, height: number}, splitType: string, initialDistance: number, minDistance: number, maxDistance: number, title: string, description: string, inputLabel: string, remainderLabel: string}}} Horizontal resize modal request. */ createHorizontalResizeModalRequest(edge, beforeRectangles, afterRectangles, sourceRectangles) { const bounds = getResizeCoordinateBounds(sourceRectangles, edge); const coordinate = afterRectangles[0].y; const coordinateStart = Math.min(...beforeRectangles.map((rectangle) => rectangle.y)); const coordinateEnd = Math.max(...afterRectangles.map((rectangle) => rectangle.y + rectangle.height)); return { coordinateStart, modalOptions: { rectangle: { width: 1, height: coordinateEnd - coordinateStart, }, splitType: RESIZE_EDGE_TYPES.HORIZONTAL, initialDistance: coordinate - coordinateStart, minDistance: bounds.min - coordinateStart, maxDistance: bounds.max - coordinateStart, title: "Горизонтальная граница", description: "Уточните точные высоты двух соседних частей. Можно изменить любое поле, второе пересчитается автоматически.", inputLabel: "Верхняя высота", remainderLabel: "Нижняя высота", }, }; } /** * Restores the previous history snapshot. * @returns {void} */ handleUndo() { const previousState = this.history.undo(this.getSnapshot()); if (!previousState) { return; } this.state = previousState; this.render(); } /** * Restores the next redo snapshot. * @returns {void} */ handleRedo() { const nextState = this.history.redo(this.getSnapshot()); if (!nextState) { return; } this.state = nextState; this.render(); } /** * Downloads the current editor state as a JSON project file. * @returns {void} */ handleSaveProject() { if (this.state.rectangles.length === 0) { this.ui.showMessage("Сначала создайте схему."); return; } const projectData = { version: PROJECT_EXPORT_VERSION, savedAt: new Date().toISOString(), state: this.getSnapshot(), }; const fileName = `${DOWNLOAD_FILE_PREFIX}-${createDownloadTimestamp()}.json`; this.downloadTextFile(fileName, JSON.stringify(projectData, null, 2), "application/json;charset=utf-8"); this.ui.showMessage("Проект сохранён в JSON."); } /** * Downloads the current drawing as a standalone SVG file. * @returns {void} */ handleExportSvg() { if (this.state.rectangles.length === 0) { this.ui.showMessage("Сначала создайте схему."); return; } try { const svgElement = document.querySelector("#drawing-root .drawing"); const svgMarkup = createExportSvgMarkup(svgElement, this.state); if (!svgMarkup) { this.ui.showMessage("Не удалось подготовить SVG."); return; } this.downloadTextFile(`rectangle-scheme-${createDownloadTimestamp()}.svg`, svgMarkup, "image/svg+xml;charset=utf-8"); this.ui.showMessage("SVG экспортирован."); } catch { this.ui.showMessage("Не удалось подготовить SVG."); } } /** * Opens the browser print dialog so the user can print or save the scheme as PDF. * @returns {void} */ handlePrintPdf() { if (this.state.rectangles.length === 0) { this.ui.showMessage("Сначала создайте схему."); return; } this.ui.showMessage("Откройте сохранение в PDF в окне печати."); window.print(); } /** * Creates a temporary download link for text-based exports. * @param {string} fileName - Name of the file shown in the browser download. * @param {string} text - File content. * @param {string} mimeType - MIME type for the generated Blob. * @returns {void} */ downloadTextFile(fileName, text, mimeType) { const blob = new Blob([text], { type: mimeType }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = fileName; document.body.append(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); } /** * Clears the current project and history. * @returns {void} */ handleClear() { this.state = this.createEmptyState(); this.history.clear(); this.ui.showMessage("Схема очищена."); this.render(); } /** * Handles global keyboard shortcuts for undo and redo. * @param {KeyboardEvent} event - Keydown event. * @returns {void} */ handleGlobalKeyDown(event) { if (this.resizeSnapshot) { return; } const isUndo = event.ctrlKey && !event.shiftKey && event.key.toLowerCase() === "z"; const isRedo = event.ctrlKey && event.key.toLowerCase() === "y"; if (!isUndo && !isRedo) { return; } event.preventDefault(); if (isUndo) { this.handleUndo(); return; } this.handleRedo(); } /** * Creates a serializable snapshot of mutable application state. * @returns {{rootWidth: number, rootHeight: number, rectangles: Array<object>, selectedId: string | null, activeTool: string | null}} State snapshot. */ getSnapshot() { return clonePlain(this.state); } /** * Renders SVG and updates toolbar state. * @returns {void} */ render() { this.drawing.setState(this.state); this.ui.update({ rectangles: this.state.rectangles, selectedId: this.state.selectedId, activeTool: this.state.activeTool, canUndo: this.history.canUndo(), canRedo: this.history.canRedo(), }); } } /** * Boots the application after the DOM is ready. * @returns {void} */ const bootstrap = () => { new RectangleEditorApp(); }; document.addEventListener("DOMContentLoaded", bootstrap);