/
diloytd
/
rectangle
Обзор
Документация
Войти
/
diloytd
/
rectangle
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/ui.js
568 строк
21 KB
diloytd
add second window for pictures
10 июл 2026, 13:43
10 июл 2026, 13:43
dc3e280
Код
Авторство
О чём код?
import { formatSize } from "./utils.js"; const TOOL_LABELS = { vertical: "вертикаль", horizontal: "горизонталь", }; const SUPPORTED_IMAGE_EXTENSIONS = ["emf", "png", "svg"]; const BROWSER_PREVIEW_EXTENSIONS = ["png", "svg"]; const MIN_IMAGE_SCALE = 25; const MAX_IMAGE_SCALE = 500; const DEFAULT_IMAGE_SCALE = 100; const IMAGE_SCALE_STEP = 10; const MIN_WORKSPACE_PANE_PERCENT = 25; const MAX_WORKSPACE_PANE_PERCENT = 75; const DEFAULT_WORKSPACE_PANE_PERCENT = 50; const EMF_CONVERSION_ENDPOINT = "/api/convert-emf"; /** * Reads a lowercase file extension from a file name. * @param {string} fileName - Name of the selected local file. * @returns {string} File extension without the dot, or an empty string when missing. */ const getFileExtension = (fileName) => fileName.split(".").pop()?.toLowerCase() ?? ""; /** * Keeps a numeric value inside the allowed image scale range. * @param {number} value - Requested scale value in percent. * @returns {number} Clamped scale value in percent. */ const clampImageScale = (value) => Math.min(MAX_IMAGE_SCALE, Math.max(MIN_IMAGE_SCALE, value)); /** * Keeps the left workspace pane width inside comfortable limits. * @param {number} value - Requested left pane width in percent. * @returns {number} Clamped left pane width in percent. */ const clampWorkspacePanePercent = (value) => Math.min(MAX_WORKSPACE_PANE_PERCENT, Math.max(MIN_WORKSPACE_PANE_PERCENT, value)); /** * Detects desktop shells where the original EMF file can be kept for native rendering. * @returns {boolean} True when the app is likely running inside Electron, WebView2 or from a local file. */ const isDesktopRuntime = () => Boolean(window.electronAPI || window.rectangleDesktop || window.chrome?.webview || window.location.protocol === "file:"); /** * Owns toolbar and status panel interactions. * The class emits semantic callbacks and does not modify geometry state directly. */ export class AppUI { /** * Creates a UI controller and wires DOM events. * @param {{onCreate: (size: {width: string, height: string}) => void, onToolChange: (tool: string) => void, onUndo: () => void, onRedo: () => void, onSaveProject: () => void, onExportSvg: () => void, onPrintPdf: () => void, onClear: () => void}} handlers - Application event handlers. * @returns {AppUI} UI controller instance. */ constructor(handlers) { this.handlers = handlers; this.elements = { sizeForm: document.querySelector("#size-form"), widthInput: document.querySelector("#width-input"), heightInput: document.querySelector("#height-input"), editorActions: document.querySelector("#editor-actions"), verticalTool: document.querySelector("#vertical-tool"), horizontalTool: document.querySelector("#horizontal-tool"), undoButton: document.querySelector("#undo-button"), redoButton: document.querySelector("#redo-button"), saveProjectButton: document.querySelector("#save-project-button"), exportSvgButton: document.querySelector("#export-svg-button"), printPdfButton: document.querySelector("#print-pdf-button"), clearButton: document.querySelector("#clear-button"), canvasExportControls: document.querySelector("#canvas-export-controls"), emptyState: document.querySelector("#empty-state"), rectCount: document.querySelector("#rect-count"), selectedInfo: document.querySelector("#selected-info"), toolInfo: document.querySelector("#tool-info"), message: document.querySelector("#message"), workspace: document.querySelector(".workspace"), workspaceDivider: document.querySelector("#workspace-divider"), imageDropzone: document.querySelector("#image-dropzone"), imageFileInput: document.querySelector("#image-file-input"), imagePlaceholder: document.querySelector("#image-placeholder"), imagePreview: document.querySelector("#image-preview"), imageFrame: document.querySelector("#image-frame"), imagePreviewImage: document.querySelector("#image-preview-image"), imageEmfPreview: document.querySelector("#image-emf-preview"), imageEmfName: document.querySelector("#image-emf-name"), imageControls: document.querySelector("#image-controls"), imageScaleDownButton: document.querySelector("#image-scale-down"), imageScaleUpButton: document.querySelector("#image-scale-up"), imageRemoveButton: document.querySelector("#image-remove-button"), }; this.messageTimer = null; this.imagePreviewUrl = null; this.imageScaleValue = DEFAULT_IMAGE_SCALE; this.workspacePanePercent = DEFAULT_WORKSPACE_PANE_PERCENT; this.workspaceResizePointerId = null; this.handleSizeSubmit = this.handleSizeSubmit.bind(this); this.handleVerticalClick = this.handleVerticalClick.bind(this); this.handleHorizontalClick = this.handleHorizontalClick.bind(this); this.handleImageDropzoneClick = this.handleImageDropzoneClick.bind(this); this.handleImageDropzoneKeyDown = this.handleImageDropzoneKeyDown.bind(this); this.handleImageDragOver = this.handleImageDragOver.bind(this); this.handleImageDragLeave = this.handleImageDragLeave.bind(this); this.handleImageDrop = this.handleImageDrop.bind(this); this.handleImageFileInputChange = this.handleImageFileInputChange.bind(this); this.handleImageScaleDownClick = this.handleImageScaleDownClick.bind(this); this.handleImageScaleUpClick = this.handleImageScaleUpClick.bind(this); this.handleImagePreviewWheel = this.handleImagePreviewWheel.bind(this); this.handleImageRemoveClick = this.handleImageRemoveClick.bind(this); this.handleWorkspaceDividerPointerDown = this.handleWorkspaceDividerPointerDown.bind(this); this.handleWorkspaceDividerPointerMove = this.handleWorkspaceDividerPointerMove.bind(this); this.handleWorkspaceDividerPointerUp = this.handleWorkspaceDividerPointerUp.bind(this); this.handleWorkspaceDividerKeyDown = this.handleWorkspaceDividerKeyDown.bind(this); this.bindEvents(); } /** * Registers toolbar event listeners. * @returns {void} */ bindEvents() { this.elements.sizeForm.addEventListener("submit", this.handleSizeSubmit); this.elements.verticalTool.addEventListener("click", this.handleVerticalClick); this.elements.horizontalTool.addEventListener("click", this.handleHorizontalClick); this.elements.undoButton.addEventListener("click", this.handlers.onUndo); this.elements.redoButton.addEventListener("click", this.handlers.onRedo); this.elements.saveProjectButton.addEventListener("click", this.handlers.onSaveProject); this.elements.exportSvgButton.addEventListener("click", this.handlers.onExportSvg); this.elements.printPdfButton.addEventListener("click", this.handlers.onPrintPdf); this.elements.clearButton.addEventListener("click", this.handlers.onClear); this.elements.imageDropzone.addEventListener("click", this.handleImageDropzoneClick); this.elements.imageDropzone.addEventListener("keydown", this.handleImageDropzoneKeyDown); this.elements.imageDropzone.addEventListener("dragover", this.handleImageDragOver); this.elements.imageDropzone.addEventListener("dragleave", this.handleImageDragLeave); this.elements.imageDropzone.addEventListener("drop", this.handleImageDrop); this.elements.imageFileInput.addEventListener("change", this.handleImageFileInputChange); this.elements.imageScaleDownButton.addEventListener("click", this.handleImageScaleDownClick); this.elements.imageScaleUpButton.addEventListener("click", this.handleImageScaleUpClick); this.elements.imagePreview.addEventListener("wheel", this.handleImagePreviewWheel, { passive: false }); this.elements.imageRemoveButton.addEventListener("click", this.handleImageRemoveClick); this.elements.workspaceDivider.addEventListener("pointerdown", this.handleWorkspaceDividerPointerDown); this.elements.workspaceDivider.addEventListener("keydown", this.handleWorkspaceDividerKeyDown); window.addEventListener("pointermove", this.handleWorkspaceDividerPointerMove); window.addEventListener("pointerup", this.handleWorkspaceDividerPointerUp); window.addEventListener("pointercancel", this.handleWorkspaceDividerPointerUp); } /** * Handles project size form submission. * @param {SubmitEvent} event - Submit event from size form. * @returns {void} */ handleSizeSubmit(event) { event.preventDefault(); this.handlers.onCreate({ width: this.elements.widthInput.value, height: this.elements.heightInput.value, }); } /** * Handles vertical tool button activation. * @returns {void} */ handleVerticalClick() { this.handlers.onToolChange("vertical"); } /** * Handles horizontal tool button activation. * @returns {void} */ handleHorizontalClick() { this.handlers.onToolChange("horizontal"); } /** * Starts resizing the two main workspace panes from the divider. * @param {PointerEvent} event - Pointer event from the workspace divider. * @returns {void} */ handleWorkspaceDividerPointerDown(event) { event.preventDefault(); this.workspaceResizePointerId = event.pointerId; this.elements.workspace.classList.add("is-resizing"); this.elements.workspaceDivider.setPointerCapture(event.pointerId); this.setWorkspacePanePercentFromPointer(event.clientX); } /** * Updates pane widths while the workspace divider is dragged. * @param {PointerEvent} event - Pointer move event. * @returns {void} */ handleWorkspaceDividerPointerMove(event) { if (this.workspaceResizePointerId !== event.pointerId) { return; } this.setWorkspacePanePercentFromPointer(event.clientX); } /** * Stops resizing the two main workspace panes. * @param {PointerEvent} event - Pointer up or cancel event. * @returns {void} */ handleWorkspaceDividerPointerUp(event) { if (this.workspaceResizePointerId !== event.pointerId) { return; } this.workspaceResizePointerId = null; this.elements.workspace.classList.remove("is-resizing"); if (this.elements.workspaceDivider.hasPointerCapture(event.pointerId)) { this.elements.workspaceDivider.releasePointerCapture(event.pointerId); } } /** * Lets keyboard users resize the workspace panes with arrow keys. * @param {KeyboardEvent} event - Keydown event from the divider. * @returns {void} */ handleWorkspaceDividerKeyDown(event) { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { return; } event.preventDefault(); const direction = event.key === "ArrowRight" ? 1 : -1; this.setWorkspacePanePercent(this.workspacePanePercent + direction * 5); } /** * Converts pointer position to left pane width and applies it to the workspace. * @param {number} clientX - Pointer X coordinate in viewport pixels. * @returns {void} */ setWorkspacePanePercentFromPointer(clientX) { const workspaceRect = this.elements.workspace.getBoundingClientRect(); const nextPercent = ((clientX - workspaceRect.left) / workspaceRect.width) * 100; this.setWorkspacePanePercent(nextPercent); } /** * Applies the left pane width while keeping both panes usable. * @param {number} percent - Requested left pane width in percent. * @returns {void} */ setWorkspacePanePercent(percent) { const nextPercent = clampWorkspacePanePercent(percent); this.workspacePanePercent = nextPercent; this.elements.workspace.style.setProperty("--image-pane-size", `${nextPercent}%`); window.dispatchEvent(new Event("resize")); } /** * Opens the hidden file picker only while the image panel is empty. * @param {MouseEvent} event - Click event from the dropzone. * @returns {void} */ handleImageDropzoneClick(event) { if (event.target === this.elements.imageFileInput) { return; } if (!this.elements.imagePreview.hidden) { return; } this.elements.imageFileInput.click(); } /** * Lets keyboard users open the image file picker from the empty dropzone. * @param {KeyboardEvent} event - Keydown event from the dropzone. * @returns {void} */ handleImageDropzoneKeyDown(event) { const isActivationKey = event.key === "Enter" || event.key === " "; if (!isActivationKey || !this.elements.imagePreview.hidden) { return; } event.preventDefault(); this.elements.imageFileInput.click(); } /** * Marks the image dropzone as active while a file is dragged over it. * @param {DragEvent} event - Dragover event from the dropzone. * @returns {void} */ handleImageDragOver(event) { event.preventDefault(); this.elements.imageDropzone.classList.add("is-dragover"); } /** * Clears the active drag state when the pointer leaves the image dropzone. * @returns {void} */ handleImageDragLeave() { this.elements.imageDropzone.classList.remove("is-dragover"); } /** * Accepts the first dropped local image file. * @param {DragEvent} event - Drop event containing local files. * @returns {void} */ handleImageDrop(event) { event.preventDefault(); this.elements.imageDropzone.classList.remove("is-dragover"); const file = event.dataTransfer?.files?.[0] ?? null; void this.handleImageFileSelect(file); } /** * Accepts the file chosen through the hidden image input. * @param {Event} event - Change event from the file input. * @returns {void} */ handleImageFileInputChange(event) { const file = event.currentTarget.files?.[0] ?? null; void this.handleImageFileSelect(file); } /** * Validates and renders the selected EMF, PNG or SVG file. * Browser EMF previews are requested from a converter endpoint; desktop shells keep the original EMF untouched. * @param {File | null} file - Selected or dropped local file. * @returns {Promise<void>} Resolves after the preview state is updated. */ async handleImageFileSelect(file) { if (!file) { return; } const extension = getFileExtension(file.name); if (!SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) { this.showMessage("Можно выбрать только EMF, PNG или SVG."); this.elements.imageFileInput.value = ""; return; } this.clearImagePreviewUrl(); this.resetImageScale(); this.elements.imagePlaceholder.hidden = true; this.elements.imagePreview.hidden = false; this.elements.imageControls.hidden = false; if (extension === "emf") { if (isDesktopRuntime()) { this.showEmfSelection(file); this.showMessage("EMF выбран без конвертации."); return; } await this.showConvertedEmfPreview(file); return; } if (!BROWSER_PREVIEW_EXTENSIONS.includes(extension)) { this.showMessage("Этот формат выбран, но браузер не может показать его напрямую."); return; } this.showBrowserImagePreview(file); this.showMessage("Картинка добавлена."); } /** * Shows a browser-native preview for PNG and SVG files. * @param {File} file - Selected PNG or SVG file. * @returns {void} */ showBrowserImagePreview(file) { this.imagePreviewUrl = URL.createObjectURL(file); this.elements.imageEmfPreview.hidden = true; this.elements.imageFrame.hidden = false; this.elements.imagePreviewImage.hidden = false; this.elements.imagePreviewImage.alt = `Выбранная картинка: ${file.name}`; this.elements.imagePreviewImage.src = this.imagePreviewUrl; } /** * Requests a PNG or SVG preview for an EMF file when running in a regular browser. * @param {File} file - Selected EMF file. * @returns {Promise<void>} Resolves after the converted preview is shown. */ async showConvertedEmfPreview(file) { this.showMessage("Конвертирую EMF для браузера..."); const formData = new FormData(); formData.append("file", file); try { const response = await fetch(EMF_CONVERSION_ENDPOINT, { method: "POST", body: formData, }); if (!response.ok) { throw new Error("EMF converter request failed."); } const convertedImage = await response.blob(); if (!convertedImage.type.startsWith("image/")) { throw new Error("EMF converter returned a non-image response."); } this.imagePreviewUrl = URL.createObjectURL(convertedImage); this.elements.imageEmfPreview.hidden = true; this.elements.imageFrame.hidden = false; this.elements.imagePreviewImage.hidden = false; this.elements.imagePreviewImage.alt = `Сконвертированная картинка: ${file.name}`; this.elements.imagePreviewImage.src = this.imagePreviewUrl; this.showMessage("EMF сконвертирован для браузера."); } catch { this.showEmfSelection(file); this.showMessage("Для браузерного просмотра EMF нужен конвертер /api/convert-emf."); } } /** * Shows that an EMF file is selected without converting it for the browser. * @param {File} file - Selected EMF file. * @returns {void} */ showEmfSelection(file) { this.elements.imageFrame.hidden = true; this.elements.imagePreviewImage.hidden = true; this.elements.imagePreviewImage.removeAttribute("src"); this.elements.imageEmfPreview.hidden = false; this.elements.imageEmfName.textContent = file.name; } /** * Decreases the selected image scale by one fixed step. * @returns {void} */ handleImageScaleDownClick() { this.setImageScale(this.imageScaleValue - IMAGE_SCALE_STEP); } /** * Increases the selected image scale by one fixed step. * @returns {void} */ handleImageScaleUpClick() { this.setImageScale(this.imageScaleValue + IMAGE_SCALE_STEP); } /** * Changes the selected image scale with the mouse wheel inside the preview. * @param {WheelEvent} event - Wheel event from the image preview. * @returns {void} */ handleImagePreviewWheel(event) { if (this.elements.imagePreview.hidden) { return; } event.preventDefault(); const direction = event.deltaY > 0 ? -1 : 1; this.setImageScale(this.imageScaleValue + direction * IMAGE_SCALE_STEP); } /** * Removes the selected image and returns the panel to the empty state. * @returns {void} */ handleImageRemoveClick() { this.clearImagePreviewUrl(); this.resetImageScale(); this.elements.imageFileInput.value = ""; this.elements.imagePlaceholder.hidden = false; this.elements.imagePreview.hidden = true; this.elements.imageFrame.hidden = true; this.elements.imagePreviewImage.hidden = true; this.elements.imagePreviewImage.removeAttribute("src"); this.elements.imageEmfPreview.hidden = true; this.elements.imageEmfName.textContent = ""; this.elements.imageControls.hidden = true; this.showMessage("Картинка удалена."); } /** * Revokes the current object URL to avoid keeping local file previews in memory. * @returns {void} */ clearImagePreviewUrl() { if (!this.imagePreviewUrl) { return; } URL.revokeObjectURL(this.imagePreviewUrl); this.imagePreviewUrl = null; } /** * Restores the image scale to 100%. * @returns {void} */ resetImageScale() { this.setImageScale(DEFAULT_IMAGE_SCALE); } /** * Applies one scale value to the draggable image frame. * @param {number} scale - Requested scale value in percent. * @returns {void} */ setImageScale(scale) { const nextScale = clampImageScale(scale); this.imageScaleValue = nextScale; this.elements.imagePreview.style.setProperty("--image-scale-size", `${nextScale}%`); } /** * Updates controls and stats from application state. * @param {{rectangles: Array<object>, selectedId: string | null, activeTool: string | null, canUndo: boolean, canRedo: boolean}} viewState - UI view state. * @returns {void} */ update(viewState) { const hasProject = viewState.rectangles.length > 0; const selectedRectangle = viewState.rectangles.find((rectangle) => rectangle.id === viewState.selectedId); this.elements.editorActions.hidden = !hasProject; this.elements.canvasExportControls.hidden = !hasProject; this.elements.emptyState.hidden = hasProject; this.elements.undoButton.disabled = !viewState.canUndo; this.elements.redoButton.disabled = !viewState.canRedo; this.elements.verticalTool.setAttribute("aria-pressed", String(viewState.activeTool === "vertical")); this.elements.horizontalTool.setAttribute("aria-pressed", String(viewState.activeTool === "horizontal")); this.elements.rectCount.textContent = String(viewState.rectangles.length); this.elements.selectedInfo.textContent = selectedRectangle ? formatSize(selectedRectangle.width, selectedRectangle.height) : "нет"; this.elements.toolInfo.textContent = viewState.activeTool ? TOOL_LABELS[viewState.activeTool] : "не выбран"; } /** * Shows a short status message above the drawing area. * @param {string} text - Message text. * @returns {void} */ showMessage(text) { window.clearTimeout(this.messageTimer); this.elements.message.textContent = text; this.elements.message.classList.add("is-visible"); this.messageTimer = window.setTimeout(() => { this.elements.message.classList.remove("is-visible"); }, 3200); } }