From 684f85b382b33c653465ceaab62352a44113719c Mon Sep 17 00:00:00 2001 From: CODEX Date: Sat, 20 Jun 2026 16:58:24 +0300 Subject: [PATCH] Add BIM model comments --- frontend/dcViewer.css | 542 ++++++++++++++++++++ frontend/dcViewer.js | 849 +++++++++++++++++++++++++++++++- frontend/index.html | 99 +++- frontend/src/ui/settingsMenu.js | 67 ++- frontend/theme.js | 8 +- server/index.js | 290 +++++++++++ 6 files changed, 1849 insertions(+), 6 deletions(-) diff --git a/frontend/dcViewer.css b/frontend/dcViewer.css index 05cdbc0..a346e73 100644 --- a/frontend/dcViewer.css +++ b/frontend/dcViewer.css @@ -62,6 +62,18 @@ --section-plane-alpha: 0.32; --section-plane-edge: #0d0d0d; --section-plane-edge-alpha: 0.8; + --comment-target-active: #c4ff67; + --comment-target-active-alpha: 1; + --comment-target-active-size: 30px; + --comment-target-passive: #f5f5fa; + --comment-target-passive-alpha: 0.58; + --comment-target-passive-size: 22px; + --nodedc-header-dropdown-bg: rgba(11, 11, 14, 0.94); + --nodedc-header-dropdown-item-bg: rgba(42, 42, 46, 0.74); + --nodedc-header-dropdown-item-hover-bg: rgba(42, 42, 46, 0.86); + --nodedc-header-dropdown-item-active-bg: rgba(47, 47, 52, 0.96); + --nodedc-header-dropdown-item-active-color: #ffffff; + --text-primary: rgba(255, 255, 255, 0.96); --section-toolbar-left: calc(12px + var(--tb-size, 25px) + var(--tb-gap, 10px)); --section-toolbar-bottom: 123px; --nodedc-glass-control-bg: var(--modal-element); @@ -507,6 +519,273 @@ header { box-shadow: none; } +.comments-panel { + position: fixed; + --env-control-h: 46px; + --env-button-radius: 17px; + --env-pill-radius: 999px; + --env-glass-control-bg: var(--nodedc-glass-control-bg); + --env-glass-control-hover: var(--nodedc-glass-control-hover); + --env-glass-control-shadow: var(--nodedc-glass-control-shadow); + top: 170px; + left: 52px; + bottom: 12px; + width: 320px; + max-height: calc(100vh - 90px); + display: flex; + flex-direction: column; + overflow: hidden; + background: color-mix(in srgb, var(--modal-bg) var(--modal-bg-alpha, 100%), transparent); + border: 1px solid var(--modal-border); + border-radius: var(--modal-radius, 16px); + box-shadow: var(--shadow); + z-index: 11; + backdrop-filter: blur(16px); +} + +.comments-panel.hidden { + display: none; +} + +.comments-panel-header { + position: sticky; + top: 0; + z-index: 5; + display: grid; + grid-template-columns: minmax(0, 1fr) var(--env-control-h); + align-items: center; + gap: 10px; + min-height: calc(var(--env-control-h) + 20px); + padding: 12px 12px 8px 16px; + background: color-mix(in srgb, var(--modal-bg) var(--modal-bg-alpha, 100%), transparent); + border-radius: var(--modal-radius, 16px) var(--modal-radius, 16px) 0 0; + color: var(--text); + font-weight: 700; + cursor: grab; + user-select: none; +} + +.comments-panel-header:active { + cursor: grabbing; +} + +.comments-panel-header h3 { + margin: 0; + min-width: 0; + font-size: 13px; + line-height: 1.2; +} + +.comments-panel-body { + min-height: 0; + overflow: auto; + padding: 0 12px 12px; +} + +.comments-list { + display: grid; + gap: 8px; +} + +.comments-empty, +.comments-loading { + color: var(--muted); + font-size: 12px; + line-height: 1.4; + padding: 10px 4px; +} + +.comment-list-item { + display: grid; + gap: 6px; + width: 100%; + min-height: 58px; + padding: 10px; + border: 1px solid var(--modal-border); + border-radius: var(--modal-radius, 10px); + background: color-mix(in srgb, var(--modal-focus) var(--modal-focus-alpha, 100%), transparent); + color: var(--text); + text-align: left; + cursor: pointer; + transition: background 140ms ease, border-color 140ms ease, transform 120ms ease; +} + +.comment-list-item:hover, +.comment-list-item.active { + border-color: color-mix(in srgb, var(--comment-target-active) 55%, var(--modal-border)); + background: color-mix(in srgb, var(--comment-target-active) 16%, var(--modal-focus)); +} + +.comment-list-item:active { + transform: translateY(1px); +} + +.comment-list-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: rgba(245, 245, 250, 0.94); + font-size: 12px; + font-weight: 800; + line-height: 1.25; + white-space: nowrap; +} + +.comment-list-body { + display: -webkit-box; + min-width: 0; + overflow: hidden; + color: rgba(245, 245, 250, 0.66); + font-size: 11px; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.comment-list-meta { + color: rgba(245, 245, 250, 0.48); + font-size: 10px; + font-weight: 700; + line-height: 1; +} + +.comments-restore-tray { + position: fixed; + left: 52px; + bottom: 12px; + width: 320px; + padding: 0 14px; + z-index: 12; + box-sizing: border-box; + pointer-events: none; +} + +.comments-restore-tray[hidden] { + display: none; +} + +.comments-restore-tray .inspector-restore-button { + pointer-events: auto; +} + +.comment-targets-layer { + position: fixed; + inset: 0; + z-index: 18; + pointer-events: none; +} + +.comment-targets-layer.hidden { + display: none; +} + +.comment-target { + position: fixed; + left: 0; + top: 0; + display: grid; + place-items: center; + width: var(--comment-target-passive-size); + height: var(--comment-target-passive-size); + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--comment-target-passive); + opacity: var(--comment-target-passive-alpha); + pointer-events: auto; + cursor: pointer; + transform: translate(-50%, -50%); + transition: color 160ms ease, height 180ms ease, opacity 160ms ease, width 180ms ease; +} + +.comment-target.active { + width: var(--comment-target-active-size); + height: var(--comment-target-active-size); + color: var(--comment-target-active); + opacity: var(--comment-target-active-alpha); +} + +.comment-target svg { + display: block; + width: 100%; + height: 100%; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; + filter: drop-shadow(0 5px 12px rgba(0, 0, 0, 0.48)); + overflow: visible; +} + +.comment-target:focus, +.comment-target:focus-visible { + outline: none; +} + +.nodedc-dropdown-surface { + position: fixed; + z-index: 30041; + padding: 0.65rem; + border: 0; + border-radius: 1.25rem; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.052), rgba(255, 255, 255, 0.012)), + var(--nodedc-header-dropdown-bg, rgba(11, 11, 14, 0.94)); + box-shadow: none; + backdrop-filter: blur(44px) saturate(1.12); + -webkit-backdrop-filter: blur(44px) saturate(1.12); +} + +.nodedc-dropdown-surface.hidden { + display: none; +} + +.bim-context-menu { + display: grid; + gap: 0.18rem; + min-width: 13.75rem; + max-width: min(18rem, calc(100vw - 24px)); +} + +.nodedc-dropdown-option { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + min-height: 2.45rem; + width: 100%; + align-items: center; + gap: 0.55rem; + border: 0; + border-radius: 0.92rem; + background: var(--nodedc-header-dropdown-item-bg, var(--nodedc-glass-control-bg, transparent)); + color: rgba(255, 255, 255, 0.68); + padding: 0.38rem 0.62rem; + text-align: left; + font: inherit; + font-size: 0.8rem; + font-weight: 760; + cursor: pointer; +} + +.nodedc-dropdown-option svg { + display: block; + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nodedc-dropdown-option:hover, +.nodedc-dropdown-option:focus-visible { + background: var(--nodedc-header-dropdown-item-hover-bg, var(--nodedc-glass-control-hover, rgba(255, 255, 255, 0.075))); + color: var(--text-primary); + outline: none; +} + .panel-section { background: transparent; border: none; @@ -1162,6 +1441,10 @@ header { display: none; } +#commentModalBackdrop { + z-index: 30045; +} + .templates-panel.measurement-modal { left: var(--measure-left, var(--templates-left, 72px)); bottom: var(--measure-bottom, var(--templates-bottom, 12px)); @@ -1297,6 +1580,265 @@ header { font-size: 12px; } +.comment-card-modal { + position: fixed; + --env-control-h: 46px; + --env-button-radius: 17px; + --env-glass-control-bg: var(--nodedc-glass-control-bg); + --env-glass-control-hover: var(--nodedc-glass-control-hover); + --env-glass-control-shadow: var(--nodedc-glass-control-shadow); + top: 96px; + left: calc(50vw - 230px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: min(520px, calc(100vw - 36px)); + max-height: min(760px, calc(100vh - 72px)); + background: color-mix(in srgb, var(--modal-bg) var(--modal-bg-alpha, 100%), transparent); + border: 1px solid var(--modal-border); + border-radius: var(--modal-radius, 16px); + box-shadow: 0 22px 60px rgba(0, 0, 0, 0.5); + z-index: 30050; + overflow: hidden; + backdrop-filter: blur(18px); +} + +.comment-card-modal.hidden { + display: none; +} + +.comment-card-modal__header { + display: grid; + grid-template-columns: minmax(0, 1fr) var(--env-control-h); + align-items: center; + gap: 10px; + min-height: calc(var(--env-control-h) + 20px); + padding: 12px 12px 8px 16px; + background: color-mix(in srgb, var(--modal-bg) var(--modal-bg-alpha, 100%), transparent); + border-radius: var(--modal-radius, 16px) var(--modal-radius, 16px) 0 0; + cursor: grab; + user-select: none; +} + +.comment-card-modal__header:active { + cursor: grabbing; +} + +.comment-card-modal__titles { + display: grid; + gap: 3px; + min-width: 0; +} + +.comment-card-modal__title { + color: var(--text); + font-size: 15px; + font-weight: 800; + line-height: 1.2; +} + +.comment-card-modal__subtitle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--muted); + font-size: 11px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.comment-card-modal__body { + display: grid; + gap: 12px; + min-height: 0; + overflow: auto; + padding: 12px 14px 14px; +} + +.comment-field { + display: grid; + gap: 6px; +} + +.comment-field span, +.comment-thread__title, +.comment-attachments__head span { + color: rgba(245, 245, 250, 0.84); + font-size: 12px; + font-weight: 800; + line-height: 1; +} + +.comment-field input, +.comment-field textarea { + width: 100%; + border: 1px solid var(--modal-border); + border-radius: var(--modal-radius, 10px); + background: var(--modal-element); + color: var(--text); + font: inherit; + font-size: 13px; + outline: none; + padding: 11px 12px; + resize: vertical; +} + +.comment-field textarea { + min-height: 86px; + line-height: 1.42; +} + +.comment-field input:focus, +.comment-field textarea:focus { + border-color: var(--modal-element-outline); + box-shadow: 0 0 0 2px rgba(255, 234, 0, 0.08); +} + +.comment-attachments, +.comment-thread { + display: grid; + gap: 8px; + border: 1px solid var(--modal-border); + border-radius: var(--modal-radius, 10px); + background: color-mix(in srgb, var(--modal-focus) var(--modal-focus-alpha, 100%), transparent); + padding: 10px; +} + +.comment-attachments--reply { + border-style: dashed; +} + +.comment-attachments__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.comment-attach-button { + min-height: 28px; + border: 0; + border-radius: 9px; + background: var(--modal-element); + color: rgba(245, 245, 250, 0.82); + cursor: pointer; + font: inherit; + font-size: 11px; + font-weight: 800; + padding: 0 10px; +} + +.comment-attach-button:hover { + background: var(--env-glass-control-hover); + color: #fff; +} + +.comment-attachment-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); + gap: 8px; +} + +.comment-attachment-thumb { + position: relative; + min-height: 70px; + overflow: hidden; + border: 1px solid var(--modal-border); + border-radius: 9px; + background: rgba(255, 255, 255, 0.05); +} + +.comment-attachment-thumb img { + display: block; + width: 100%; + height: 82px; + object-fit: cover; +} + +.comment-attachment-thumb button { + position: absolute; + top: 4px; + right: 4px; + display: grid; + place-items: center; + width: 22px; + height: 22px; + border: 0; + border-radius: 8px; + background: rgba(12, 12, 14, 0.76); + color: #fff; + cursor: pointer; + font-weight: 900; + line-height: 1; +} + +.comment-thread__list { + display: grid; + gap: 8px; +} + +.comment-thread-empty { + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.comment-thread-message { + display: grid; + gap: 6px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.045); + padding: 9px; +} + +.comment-thread-message__head { + display: flex; + min-width: 0; + align-items: baseline; + justify-content: space-between; + gap: 8px; + color: rgba(245, 245, 250, 0.56); + font-size: 10px; + font-weight: 700; +} + +.comment-thread-message__head strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: rgba(245, 245, 250, 0.9); + white-space: nowrap; +} + +.comment-thread-message__body { + color: rgba(245, 245, 250, 0.78); + font-size: 12px; + line-height: 1.42; + white-space: pre-wrap; +} + +.comment-card-modal__footer { + position: sticky; + bottom: 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + padding: 12px 14px; + background: color-mix(in srgb, var(--modal-bg) var(--modal-bg-alpha, 100%), transparent); + border-top: 1px solid var(--modal-border); +} + +.comment-card-modal__footer .primary-btn, +.comment-card-modal__footer .secondary-btn { + width: 100%; +} + +.comment-card-modal .error-text { + min-height: 16px; + color: var(--error); + font-size: 12px; +} + .settings-panel { position: fixed; --env-control-h: 46px; diff --git a/frontend/dcViewer.js b/frontend/dcViewer.js index 24d94ac..7892e7b 100644 --- a/frontend/dcViewer.js +++ b/frontend/dcViewer.js @@ -4,7 +4,7 @@ const SDK_URL = "./lib/dc-camera-context.es.js?v=3"; import { createLogo } from "./src/ui/logo.js"; import { toolbarConfig, applyToolbarVars } from "./src/ui/toolbar.js"; import { createTemplatesMenu } from "./src/ui/templatesMenu.js?v=2"; -import { createSettingsMenu } from "./src/ui/settingsMenu.js?v=8"; +import { createSettingsMenu } from "./src/ui/settingsMenu.js?v=9"; import { createMeasurementModal } from "./src/ui/measurementModal.js"; import { createGlassSelect } from "./src/ui/glassSelect.js"; import { createSliderControl } from "./src/ui/controls/slider.js"; @@ -40,9 +40,36 @@ const settingsButton = document.querySelector('[data-action="settings"]'); const templatesButton = document.querySelector('[data-action="templates"]'); const shareButton = document.querySelector('[data-action="share"]'); const sectionButton = document.querySelector('[data-action="section"]'); +const commentsButton = document.querySelector('[data-action="comments"]'); const sectionToolbar = document.getElementById("sectionToolbar"); const sectionOverviewCanvas = document.getElementById("sectionPlanesOverview"); const projectionToggle = document.querySelector('[data-action="projection"]'); +const commentsPanel = document.getElementById("commentsPanel"); +const commentsPanelHeader = document.getElementById("commentsPanelHeader"); +const commentsCollapseButton = document.getElementById("commentsCollapseButton"); +const commentsRestoreTray = document.getElementById("commentsRestoreTray"); +const commentsRestoreButton = document.getElementById("commentsRestoreButton"); +const commentsList = document.getElementById("commentsList"); +const commentTargetsLayer = document.getElementById("commentTargetsLayer"); +const commentContextMenu = document.getElementById("commentContextMenu"); +const commentModalBackdrop = document.getElementById("commentModalBackdrop"); +const commentModal = document.getElementById("commentModal"); +const commentModalHeader = document.getElementById("commentModalHeader"); +const commentModalClose = document.getElementById("commentModalClose"); +const commentModalSubtitle = document.getElementById("commentModalSubtitle"); +const commentTitleInput = document.getElementById("commentTitleInput"); +const commentBodyInput = document.getElementById("commentBodyInput"); +const commentAttachButton = document.getElementById("commentAttachButton"); +const commentAttachmentInput = document.getElementById("commentAttachmentInput"); +const commentAttachmentGrid = document.getElementById("commentAttachmentGrid"); +const commentThreadList = document.getElementById("commentThreadList"); +const commentReplyInput = document.getElementById("commentReplyInput"); +const commentReplyAttachButton = document.getElementById("commentReplyAttachButton"); +const commentReplyAttachmentInput = document.getElementById("commentReplyAttachmentInput"); +const commentReplyAttachmentGrid = document.getElementById("commentReplyAttachmentGrid"); +const commentModalError = document.getElementById("commentModalError"); +const commentCancelButton = document.getElementById("commentCancelButton"); +const commentApplyButton = document.getElementById("commentApplyButton"); const treeContainer = document.getElementById("treeContainer"); const displayModeControl = document.getElementById("displayModeControl"); const displayModeSelectHost = document.getElementById("displayModeSelect"); @@ -123,6 +150,7 @@ const API_BASE = "/api/projects"; const MODEL_SETTINGS_API = "/api/model-settings"; const AUTH_SESSION_API = "/api/auth/session"; const SHARES_API = "/api/shares"; +const COMMENTS_API = "/api/comments"; const acceptByType = { xkt: ".xkt", @@ -258,6 +286,23 @@ let activeSectionPlaneId = null; let sectionPlaneCounter = 0; let sectionModeActive = false; let activeSectionAxis = null; +let commentsModeActive = false; +let commentsCollapsed = false; +let commentRecords = []; +let commentSourcesLoaded = new Set(); +let commentContextTarget = null; +let commentModalMode = "create"; +let commentModalCommentId = null; +let activeCommentId = null; +let hoverCommentId = null; +let commentDraftAttachments = []; +let commentReplyAttachments = []; +let commentTargetRaf = 0; +let commentLongPressTimer = null; +let commentLongPressStart = null; +let suppressNextCanvasClick = false; +let floatingWindowOrder = []; +const floatingWindowElements = new Map(); let objectContextModal = null; let mathUtil = null; let selectionState = null; // { ids, modelId, pivotCenter, baseTransforms: Map, transform } @@ -449,11 +494,690 @@ const getShareTokenFromPath = () => { return match ? decodeURIComponent(match[1]) : null; }; +const COMMENT_TARGET_ICON = ` + +`; + +const formatDateTime = (value) => { + if (!value) return ""; + try { + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); + } catch (_) { + return String(value); + } +}; + +const bringFloatingWindowToFront = (id) => { + if (!id) return; + floatingWindowOrder = [...floatingWindowOrder.filter((item) => item !== id), id]; + const zBase = 21000; + floatingWindowOrder.forEach((item, index) => { + const element = floatingWindowElements.get(item); + if (element) element.style.zIndex = `${zBase + index * 10}`; + }); +}; + +const registerFloatingWindowElement = (id, element) => { + if (!id || !element) return; + floatingWindowElements.set(id, element); + if (!floatingWindowOrder.includes(id)) floatingWindowOrder.push(id); + element.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + bringFloatingWindowToFront(id); + }, true); + bringFloatingWindowToFront(id); +}; + +const extractUploadIdentityFromSrc = (src) => { + if (!src || typeof src !== "string") return {}; + const match = src.match(/(?:^|\/)uploads\/([^/]+)\/([^/?#]+)/); + return match ? { projectId: match[1], assetId: match[2] } : {}; +}; + +const getCommentSourceForEntry = (entry = getPrimaryModelEntry()) => { + if (!entry) return null; + const meta = entry.meta || {}; + const src = getModelSettingsSrc(entry) || + normalizeModelSettingsSrc(meta.sourceSrc) || + normalizeModelSettingsSrc(meta.artifactSrc) || + normalizeModelSettingsSrc(entry.src) || + ""; + const uploadIdentity = extractUploadIdentityFromSrc(src); + return { + src, + sourceSrc: src, + settingsSrc: normalizeModelSettingsSrc(meta.settingsSrc) || src, + projectId: meta.projectId || uploadIdentity.projectId || currentProjectMeta?.id || null, + assetId: meta.assetId || uploadIdentity.assetId || null, + versionId: meta.versionId || null, + }; +}; + +const sourceParamsForCommentSource = (source) => { + const params = new URLSearchParams(); + if (!source) return params; + if (source.projectId) params.set("projectId", source.projectId); + if (source.assetId) params.set("assetId", source.assetId); + if (source.src) params.set("src", source.src); + if (source.sourceSrc) params.set("sourceSrc", source.sourceSrc); + if (source.settingsSrc) params.set("settingsSrc", source.settingsSrc); + return params; +}; + +const sourceCacheKey = (source) => { + const params = sourceParamsForCommentSource(source); + return params.toString(); +}; + +const mergeCommentRecords = (items) => { + const byId = new Map(commentRecords.map((comment) => [comment.id, comment])); + (items || []).forEach((comment) => { + if (comment?.id) byId.set(comment.id, comment); + }); + commentRecords = [...byId.values()] + .filter((comment) => !comment.deletedAt) + .sort((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || ""))); +}; + +const loadCommentsForEntry = async (entry, { force = false } = {}) => { + if (!entry || isGuestMode()) return []; + const source = getCommentSourceForEntry(entry); + const key = sourceCacheKey(source); + if (!key) return []; + if (!force && commentSourcesLoaded.has(key)) return commentRecords; + const params = sourceParamsForCommentSource(source); + const payload = await fetchJSON(`${COMMENTS_API}?${params.toString()}`); + commentSourcesLoaded.add(key); + mergeCommentRecords(payload?.comments || []); + renderCommentsPanel(); + renderCommentTargets(); + return payload?.comments || []; +}; + +const loadCommentsForActiveModels = async ({ force = false } = {}) => { + if (isGuestMode()) return; + const entries = activeModels.length ? [...activeModels] : [getPrimaryModelEntry()].filter(Boolean); + for (const entry of entries) { + await loadCommentsForEntry(entry, { force }).catch((err) => { + console.warn("comments load failed", err); + }); + } +}; + +const getCommentDisplayBody = (comment) => { + if (comment?.body) return comment.body; + const lastMessage = Array.isArray(comment?.messages) ? comment.messages[comment.messages.length - 1] : null; + return lastMessage?.body || ""; +}; + +const renderCommentsPanel = () => { + if (!commentsList) return; + commentsList.replaceChildren(); + if (!commentsModeActive) return; + if (!commentRecords.length) { + const empty = document.createElement("div"); + empty.className = "comments-empty"; + empty.textContent = "Комментариев пока нет"; + commentsList.appendChild(empty); + return; + } + commentRecords.forEach((comment) => { + const item = document.createElement("button"); + item.type = "button"; + item.className = "comment-list-item"; + item.classList.toggle("active", activeCommentId === comment.id || hoverCommentId === comment.id); + item.dataset.commentId = comment.id; + const title = document.createElement("div"); + title.className = "comment-list-title"; + title.textContent = comment.title || "Комментарий"; + const body = document.createElement("div"); + body.className = "comment-list-body"; + body.textContent = getCommentDisplayBody(comment) || "Без описания"; + const meta = document.createElement("div"); + meta.className = "comment-list-meta"; + const created = formatDateTime(comment.createdAt); + const updated = formatDateTime(comment.updatedAt); + meta.textContent = updated && updated !== created ? `Создан: ${created} · Изменён: ${updated}` : `Создан: ${created}`; + item.append(title, body, meta); + item.addEventListener("mouseenter", () => { + hoverCommentId = comment.id; + renderCommentsPanel(); + renderCommentTargets(); + }); + item.addEventListener("mouseleave", () => { + if (hoverCommentId === comment.id) hoverCommentId = null; + renderCommentsPanel(); + renderCommentTargets(); + }); + item.addEventListener("click", () => openCommentModalForComment(comment.id)); + commentsList.appendChild(item); + }); +}; + +const commentTargetsForRender = () => { + const targets = [...commentRecords]; + if (commentModalMode === "create" && commentContextTarget?.worldPos) { + targets.push({ + id: "__draft_comment__", + title: "Новый комментарий", + worldPos: commentContextTarget.worldPos, + draft: true, + }); + } + return targets; +}; + +const updateCommentTargetPositions = () => { + if (!viewer?.scene?.camera || !commentTargetsLayer || !commentsModeActive) return; + const canvas = viewer.scene?.canvas?.canvas || mainCanvas; + const rect = canvas?.getBoundingClientRect?.(); + if (!rect) return; + commentTargetsLayer.querySelectorAll(".comment-target").forEach((targetEl) => { + const id = targetEl.dataset.commentId; + const record = commentTargetsForRender().find((comment) => comment.id === id); + const pos = record?.worldPos; + if (!Array.isArray(pos) || pos.length !== 3) { + targetEl.hidden = true; + return; + } + let canvasPos = null; + try { + canvasPos = viewer.scene.camera.projectWorldPos(pos); + } catch (_) { + canvasPos = null; + } + if (!canvasPos || !Number.isFinite(canvasPos[0]) || !Number.isFinite(canvasPos[1])) { + targetEl.hidden = true; + return; + } + targetEl.hidden = false; + targetEl.style.left = `${Math.round(rect.left + canvasPos[0])}px`; + targetEl.style.top = `${Math.round(rect.top + canvasPos[1])}px`; + }); +}; + +const startCommentTargetLoop = () => { + if (commentTargetRaf) return; + const tick = () => { + commentTargetRaf = 0; + if (!commentsModeActive) return; + updateCommentTargetPositions(); + commentTargetRaf = window.requestAnimationFrame(tick); + }; + commentTargetRaf = window.requestAnimationFrame(tick); +}; + +const stopCommentTargetLoop = () => { + if (commentTargetRaf) { + window.cancelAnimationFrame(commentTargetRaf); + commentTargetRaf = 0; + } +}; + +const renderCommentTargets = () => { + if (!commentTargetsLayer) return; + commentTargetsLayer.replaceChildren(); + if (!commentsModeActive || isGuestMode()) { + commentTargetsLayer.classList.add("hidden"); + stopCommentTargetLoop(); + return; + } + const targets = commentTargetsForRender(); + targets.forEach((comment) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "comment-target"; + btn.classList.toggle("active", comment.draft || activeCommentId === comment.id || hoverCommentId === comment.id); + btn.dataset.commentId = comment.id; + btn.title = comment.title || "Комментарий"; + btn.setAttribute("aria-label", comment.title || "Комментарий"); + btn.innerHTML = COMMENT_TARGET_ICON; + btn.addEventListener("mouseenter", () => { + if (!comment.draft) hoverCommentId = comment.id; + renderCommentTargets(); + renderCommentsPanel(); + }); + btn.addEventListener("mouseleave", () => { + if (hoverCommentId === comment.id) hoverCommentId = null; + renderCommentTargets(); + renderCommentsPanel(); + }); + btn.addEventListener("click", (event) => { + event.stopPropagation(); + if (!comment.draft) openCommentModalForComment(comment.id); + }); + commentTargetsLayer.appendChild(btn); + }); + commentTargetsLayer.classList.toggle("hidden", targets.length === 0); + updateCommentTargetPositions(); + startCommentTargetLoop(); +}; + +const updateCommentsVisibility = () => { + const visible = commentsModeActive && !isGuestMode() && activeModels.length > 0; + setToolbarButtonActive(commentsButton, visible); + if (commentsPanel) { + commentsPanel.classList.toggle("hidden", !visible || commentsCollapsed); + } + if (commentsRestoreTray) { + commentsRestoreTray.hidden = !visible || !commentsCollapsed; + } + if (visible) { + renderCommentsPanel(); + renderCommentTargets(); + loadCommentsForActiveModels().catch(() => {}); + } else { + commentsPanel?.classList.add("hidden"); + if (commentsRestoreTray) commentsRestoreTray.hidden = true; + commentTargetsLayer?.classList.add("hidden"); + hideCommentContextMenu(); + stopCommentTargetLoop(); + } +}; + +const setCommentsCollapsed = (collapsed) => { + commentsCollapsed = !!collapsed; + updateCommentsVisibility(); +}; + +const toggleCommentsMode = async () => { + if (isGuestMode()) return; + if (!activeModels.length) { + setStatus("Сначала загрузите модель"); + return; + } + commentsModeActive = !commentsModeActive; + if (commentsModeActive) { + commentsCollapsed = false; + bringFloatingWindowToFront("comments"); + await loadCommentsForActiveModels({ force: true }).catch(() => {}); + } else { + activeCommentId = null; + hoverCommentId = null; + } + updateCommentsVisibility(); +}; + +const hideCommentContextMenu = () => { + commentContextMenu?.classList.add("hidden"); +}; + +const clampMenuPosition = (x, y, menu) => { + const width = menu?.offsetWidth || 220; + const height = menu?.offsetHeight || 54; + return { + x: Math.max(8, Math.min(window.innerWidth - width - 8, x)), + y: Math.max(8, Math.min(window.innerHeight - height - 8, y)), + }; +}; + +const getCanvasPointFromEvent = (event) => { + const canvas = viewer?.scene?.canvas?.canvas || mainCanvas; + const rect = canvas?.getBoundingClientRect?.(); + if (!rect) return null; + return { + canvasPos: [event.clientX - rect.left, event.clientY - rect.top], + pagePos: [event.clientX, event.clientY], + }; +}; + +const getWorldPosFromPick = (hit) => { + const direct = hit?.worldPos || hit?.worldPosition || hit?.position || hit?.origin; + if (direct && direct.length >= 3) { + return [Number(direct[0]), Number(direct[1]), Number(direct[2])].every(Number.isFinite) + ? [Number(direct[0]), Number(direct[1]), Number(direct[2])] + : null; + } + const entity = hit?.entity; + const aabb = entity?.aabb || entity?.model?.aabb || null; + const aabbArray = Array.from(aabb || []); + return aabbArray.length === 6 ? centerFromAABB(aabbArray) : null; +}; + +const buildCommentTargetFromEvent = (event) => { + if (isGuestMode() || !viewer?.scene) return null; + const point = getCanvasPointFromEvent(event); + if (!point) return null; + const hit = viewer.scene.pick({ + canvasPos: point.canvasPos, + pickSurface: true, + }); + if (!hit) return null; + const entity = hit.entity || null; + const modelId = entity?.model?.id || entity?.modelId || null; + const objectId = entity?.id || entity?.objectId || null; + const entry = activeModels.find((item) => item?.model?.id === modelId) || getPrimaryModelEntry(); + const source = getCommentSourceForEntry(entry); + const worldPos = getWorldPosFromPick(hit); + if (!source || !worldPos) return null; + return { + ...source, + modelId, + objectId, + worldPos, + canvasPos: point.canvasPos, + pagePos: point.pagePos, + }; +}; + +const showCommentContextMenu = (target, x, y) => { + if (!commentContextMenu || !target) return; + commentContextTarget = target; + commentContextMenu.classList.remove("hidden"); + const pos = clampMenuPosition(x, y, commentContextMenu); + commentContextMenu.style.left = `${pos.x}px`; + commentContextMenu.style.top = `${pos.y}px`; +}; + +const openCommentContextMenuFromEvent = (event) => { + if (isGuestMode()) return false; + const target = buildCommentTargetFromEvent(event); + if (!target) { + hideCommentContextMenu(); + return false; + } + event.preventDefault(); + event.stopPropagation(); + showCommentContextMenu(target, event.clientX, event.clientY); + return true; +}; + +const readImageFiles = async (fileList) => { + const files = Array.from(fileList || []).filter((file) => file?.type?.startsWith("image/")).slice(0, 8); + const readOne = (file) => new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve({ + id: `att_${Date.now()}_${Math.random().toString(36).slice(2)}`, + name: file.name, + type: file.type, + size: file.size, + dataUrl: String(reader.result || ""), + }); + reader.onerror = () => resolve(null); + reader.readAsDataURL(file); + }); + const items = await Promise.all(files.map(readOne)); + return items.filter(Boolean); +}; + +const renderAttachmentGrid = (grid, attachments, onRemove) => { + if (!grid) return; + grid.replaceChildren(); + (attachments || []).forEach((attachment, index) => { + const thumb = document.createElement("div"); + thumb.className = "comment-attachment-thumb"; + const img = document.createElement("img"); + img.src = attachment.dataUrl; + img.alt = attachment.name || "attachment"; + const remove = document.createElement("button"); + remove.type = "button"; + remove.setAttribute("aria-label", "Удалить картинку"); + remove.textContent = "×"; + remove.addEventListener("click", () => onRemove?.(index)); + thumb.append(img, remove); + grid.appendChild(thumb); + }); +}; + +const renderThreadAttachments = (container, attachments) => { + if (!attachments?.length) return; + const grid = document.createElement("div"); + grid.className = "comment-attachment-grid"; + attachments.forEach((attachment) => { + const thumb = document.createElement("div"); + thumb.className = "comment-attachment-thumb"; + const img = document.createElement("img"); + img.src = attachment.dataUrl; + img.alt = attachment.name || "attachment"; + thumb.appendChild(img); + grid.appendChild(thumb); + }); + container.appendChild(grid); +}; + +const renderCommentThread = (comment) => { + if (!commentThreadList) return; + commentThreadList.replaceChildren(); + const messages = Array.isArray(comment?.messages) ? comment.messages : []; + if (!messages.length) { + const empty = document.createElement("div"); + empty.className = "comment-thread-empty"; + empty.textContent = "Комментариев пока нет"; + commentThreadList.appendChild(empty); + return; + } + messages.forEach((message) => { + const item = document.createElement("div"); + item.className = "comment-thread-message"; + const head = document.createElement("div"); + head.className = "comment-thread-message__head"; + const author = document.createElement("strong"); + author.textContent = message?.createdBy?.name || message?.createdBy?.email || "Пользователь"; + const date = document.createElement("span"); + date.textContent = formatDateTime(message?.updatedAt || message?.createdAt); + head.append(author, date); + const body = document.createElement("div"); + body.className = "comment-thread-message__body"; + body.textContent = message?.body || ""; + item.append(head, body); + renderThreadAttachments(item, message?.attachments || []); + commentThreadList.appendChild(item); + }); +}; + +const resetCommentModalAttachments = () => { + commentDraftAttachments = []; + commentReplyAttachments = []; + renderModalAttachmentInputs(); +}; + +const renderModalAttachmentInputs = () => { + renderAttachmentGrid(commentAttachmentGrid, commentDraftAttachments, (index) => { + commentDraftAttachments.splice(index, 1); + renderModalAttachmentInputs(); + }); + renderAttachmentGrid(commentReplyAttachmentGrid, commentReplyAttachments, (index) => { + commentReplyAttachments.splice(index, 1); + renderModalAttachmentInputs(); + }); +}; + +const openCommentModalForTarget = () => { + if (!commentContextTarget || !commentModal) return; + commentsModeActive = true; + commentsCollapsed = false; + commentModalMode = "create"; + commentModalCommentId = null; + activeCommentId = "__draft_comment__"; + commentTitleInput && (commentTitleInput.value = ""); + commentBodyInput && (commentBodyInput.value = ""); + commentReplyInput && (commentReplyInput.value = ""); + if (commentModalSubtitle) { + const [x, y, z] = commentContextTarget.worldPos || []; + commentModalSubtitle.textContent = Number.isFinite(x) ? `Точка: ${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)}` : "Новый таргет"; + } + if (commentModalError) commentModalError.textContent = ""; + resetCommentModalAttachments(); + renderCommentThread(null); + commentModalBackdrop?.classList.remove("hidden"); + commentModal.classList.remove("hidden"); + bringFloatingWindowToFront("comments"); + updateCommentsVisibility(); + commentTitleInput?.focus(); +}; + +const openCommentModalForComment = (commentId) => { + const comment = commentRecords.find((item) => item.id === commentId); + if (!comment || !commentModal) return; + commentModalMode = "edit"; + commentModalCommentId = comment.id; + activeCommentId = comment.id; + commentTitleInput && (commentTitleInput.value = comment.title || ""); + commentBodyInput && (commentBodyInput.value = comment.body || ""); + commentReplyInput && (commentReplyInput.value = ""); + if (commentModalSubtitle) { + commentModalSubtitle.textContent = `Создан: ${formatDateTime(comment.createdAt)} · Изменён: ${formatDateTime(comment.updatedAt)}`; + } + if (commentModalError) commentModalError.textContent = ""; + resetCommentModalAttachments(); + renderCommentThread(comment); + commentModalBackdrop?.classList.remove("hidden"); + commentModal.classList.remove("hidden"); + renderCommentsPanel(); + renderCommentTargets(); +}; + +const closeCommentModal = () => { + commentModalBackdrop?.classList.add("hidden"); + commentModal?.classList.add("hidden"); + if (commentModalMode === "create") { + commentContextTarget = null; + } + activeCommentId = null; + commentModalMode = "create"; + commentModalCommentId = null; + if (commentModalError) commentModalError.textContent = ""; + renderCommentsPanel(); + renderCommentTargets(); +}; + +const buildCommentPayload = () => ({ + ...(commentContextTarget || {}), + title: commentTitleInput?.value?.trim() || "", + body: commentBodyInput?.value?.trim() || "", + attachments: commentDraftAttachments, +}); + +const applyCommentModal = async () => { + if (isGuestMode()) return; + const title = commentTitleInput?.value?.trim() || ""; + const body = commentBodyInput?.value?.trim() || ""; + const replyBody = commentReplyInput?.value?.trim() || ""; + if (!title && !body && commentModalMode === "create") { + if (commentModalError) commentModalError.textContent = "Заполните тему или описание"; + return; + } + if (commentApplyButton) { + commentApplyButton.disabled = true; + commentApplyButton.textContent = "Сохранение..."; + } + try { + let payload = null; + if (commentModalMode === "create") { + payload = await fetchJSON(COMMENTS_API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildCommentPayload()), + }); + } else if (commentModalCommentId) { + const original = commentRecords.find((item) => item.id === commentModalCommentId); + const patchPayload = {}; + if (!original || title !== (original.title || "")) patchPayload.title = title; + if (!original || body !== (original.body || "")) patchPayload.body = body; + if (replyBody || commentReplyAttachments.length) { + patchPayload.message = { + body: replyBody, + attachments: commentReplyAttachments, + }; + } + if (!Object.keys(patchPayload).length) { + closeCommentModal(); + return; + } + payload = await fetchJSON(`${COMMENTS_API}/${encodeURIComponent(commentModalCommentId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patchPayload), + }); + } + const saved = payload?.comment; + if (saved) { + mergeCommentRecords([saved]); + activeCommentId = saved.id; + commentContextTarget = null; + commentsModeActive = true; + commentsCollapsed = false; + closeCommentModal(); + updateCommentsVisibility(); + setStatus("Комментарий сохранён"); + } + } catch (err) { + console.error(err); + if (commentModalError) commentModalError.textContent = err.message || "Не удалось сохранить комментарий"; + } finally { + if (commentApplyButton) { + commentApplyButton.disabled = false; + commentApplyButton.textContent = "Применить"; + } + } +}; + +const setupDraggableFixedWindow = (element, handle) => { + if (!element || !handle) return; + let drag = null; + handle.addEventListener("pointerdown", (event) => { + if (event.button !== 0 || event.target.closest("button,input,textarea")) return; + const rect = element.getBoundingClientRect(); + drag = { + pointerId: event.pointerId, + dx: event.clientX - rect.left, + dy: event.clientY - rect.top, + }; + element.style.left = `${rect.left}px`; + element.style.top = `${rect.top}px`; + element.style.right = "auto"; + element.style.bottom = "auto"; + element.style.transform = "none"; + handle.setPointerCapture?.(event.pointerId); + event.preventDefault(); + }); + handle.addEventListener("pointermove", (event) => { + if (!drag || drag.pointerId !== event.pointerId) return; + const width = element.offsetWidth || 320; + const height = element.offsetHeight || 320; + const left = Math.max(8, Math.min(window.innerWidth - width - 8, event.clientX - drag.dx)); + const top = Math.max(8, Math.min(window.innerHeight - Math.min(height, window.innerHeight - 16) - 8, event.clientY - drag.dy)); + element.style.left = `${left}px`; + element.style.top = `${top}px`; + }); + const endDrag = (event) => { + if (!drag || drag.pointerId !== event.pointerId) return; + handle.releasePointerCapture?.(event.pointerId); + drag = null; + }; + handle.addEventListener("pointerup", endDrag); + handle.addEventListener("pointercancel", endDrag); +}; + const applyRuntimeMode = () => { document.body.dataset.viewerMode = viewerMode; if (bottomToolbar) { bottomToolbar.classList.toggle("hidden", isGuestMode()); } + if (commentsButton) { + commentsButton.classList.toggle("hidden", isGuestMode()); + commentsButton.disabled = isGuestMode() || activeModels.length === 0; + } + if (isGuestMode()) { + commentsModeActive = false; + commentsCollapsed = false; + commentContextTarget = null; + activeCommentId = null; + hoverCommentId = null; + commentsPanel?.classList.add("hidden"); + if (commentsRestoreTray) commentsRestoreTray.hidden = true; + commentTargetsLayer?.classList.add("hidden"); + hideCommentContextMenu(); + stopCommentTargetLoop(); + } if (shareButton) { const canShowShare = !isGuestMode() && runtimeSessionInfo.canShare && activeModels.length > 0; shareButton.classList.toggle("hidden", !canShowShare); @@ -1488,7 +2212,18 @@ const updatePanelsVisibility = () => { projectNameInput.value = ""; } } + if (!hasModels) { + commentsModeActive = false; + commentsCollapsed = false; + commentContextTarget = null; + activeCommentId = null; + hoverCommentId = null; + commentRecords = []; + commentSourcesLoaded.clear(); + closeCommentModal(); + } updateSectionControls(); + updateCommentsVisibility(); applyRuntimeMode(); }; @@ -3298,20 +4033,67 @@ const initViewer = async () => { const canvasEl = viewer.scene?.canvas?.canvas; if (canvasEl) { let downPos = null; + const clearCommentLongPress = () => { + if (commentLongPressTimer) { + window.clearTimeout(commentLongPressTimer); + commentLongPressTimer = null; + } + commentLongPressStart = null; + }; canvasEl.addEventListener("contextmenu", (event) => { - event.preventDefault(); + const opened = openCommentContextMenuFromEvent(event); + if (!opened) event.preventDefault(); }); canvasEl.addEventListener("pointerdown", (event) => { if (measureActive) { downPos = null; + clearCommentLongPress(); return; } downPos = { x: event.clientX, y: event.clientY }; + if (!isGuestMode() && activeModels.length && (event.pointerType === "touch" || event.pointerType === "pen")) { + clearCommentLongPress(); + commentLongPressStart = { + x: event.clientX, + y: event.clientY, + }; + commentLongPressTimer = window.setTimeout(() => { + if (!commentLongPressStart) return; + const syntheticEvent = { + clientX: commentLongPressStart.x, + clientY: commentLongPressStart.y, + preventDefault: () => {}, + stopPropagation: () => {}, + }; + if (openCommentContextMenuFromEvent(syntheticEvent)) { + suppressNextCanvasClick = true; + } + clearCommentLongPress(); + }, 620); + } + }); + canvasEl.addEventListener("pointermove", (event) => { + if (!commentLongPressStart) return; + const dx = Math.abs(event.clientX - commentLongPressStart.x); + const dy = Math.abs(event.clientY - commentLongPressStart.y); + if (dx > 10 || dy > 10) clearCommentLongPress(); + }); + canvasEl.addEventListener("pointerup", clearCommentLongPress); + canvasEl.addEventListener("pointercancel", clearCommentLongPress); + canvasEl.addEventListener("pointerleave", clearCommentLongPress); + canvasEl.addEventListener("wheel", () => { + hideCommentContextMenu(); + clearCommentLongPress(); }); canvasEl.addEventListener("click", (event) => { if (measureActive) { return; } + if (suppressNextCanvasClick) { + suppressNextCanvasClick = false; + downPos = null; + return; + } if (downPos) { const dx = Math.abs(event.clientX - downPos.x); const dy = Math.abs(event.clientY - downPos.y); @@ -4051,6 +4833,9 @@ const loadProjectSnapshot = async (project) => { if (key && key.startsWith("section")) { applySectionPlaneTheme(); } + if (key && key.startsWith("commentTarget")) { + renderCommentTargets(); + } focusCanvasAndControls(); }, onShow: () => { @@ -4127,6 +4912,52 @@ const loadProjectSnapshot = async (project) => { } inspectorCollapseButton?.addEventListener("click", () => setInspectorCollapsed(true)); inspectorRestoreButton?.addEventListener("click", () => setInspectorCollapsed(false)); + registerFloatingWindowElement("inspector", sidePanelEl); + registerFloatingWindowElement("comments", commentsPanel); + setupDraggableFixedWindow(commentsPanel, commentsPanelHeader); + setupDraggableFixedWindow(commentModal, commentModalHeader); + commentsCollapseButton?.addEventListener("click", () => setCommentsCollapsed(true)); + commentsRestoreButton?.addEventListener("click", () => { + commentsModeActive = true; + commentsCollapsed = false; + bringFloatingWindowToFront("comments"); + updateCommentsVisibility(); + }); + commentContextMenu?.addEventListener("click", (event) => { + const button = event.target?.closest?.("[data-comment-menu-action]"); + if (!button) return; + event.preventDefault(); + event.stopPropagation(); + if (button.dataset.commentMenuAction === "create") { + hideCommentContextMenu(); + openCommentModalForTarget(); + } + }); + document.addEventListener("click", (event) => { + if (!commentContextMenu || commentContextMenu.classList.contains("hidden")) return; + if (commentContextMenu.contains(event.target)) return; + hideCommentContextMenu(); + }); + commentModalClose?.addEventListener("click", closeCommentModal); + commentCancelButton?.addEventListener("click", closeCommentModal); + commentApplyButton?.addEventListener("click", applyCommentModal); + commentModalBackdrop?.addEventListener("click", (event) => { + if (event.target === commentModalBackdrop) closeCommentModal(); + }); + commentAttachButton?.addEventListener("click", () => commentAttachmentInput?.click()); + commentReplyAttachButton?.addEventListener("click", () => commentReplyAttachmentInput?.click()); + commentAttachmentInput?.addEventListener("change", async (event) => { + const files = await readImageFiles(event.target.files); + commentDraftAttachments = [...commentDraftAttachments, ...files].slice(0, 8); + event.target.value = ""; + renderModalAttachmentInputs(); + }); + commentReplyAttachmentInput?.addEventListener("change", async (event) => { + const files = await readImageFiles(event.target.files); + commentReplyAttachments = [...commentReplyAttachments, ...files].slice(0, 8); + event.target.value = ""; + renderModalAttachmentInputs(); + }); if (!isGuestMode()) { loadProjectsList().catch(() => { templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" }); @@ -4303,6 +5134,18 @@ const loadProjectSnapshot = async (project) => { document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; + if (commentModal && !commentModal.classList.contains("hidden")) { + closeCommentModal(); + e.preventDefault(); + e.stopPropagation(); + return; + } + if (commentContextMenu && !commentContextMenu.classList.contains("hidden")) { + hideCommentContextMenu(); + e.preventDefault(); + e.stopPropagation(); + return; + } if (e.target && ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return; if (shareModal && !shareModal.classList.contains("hidden")) { closeShareModal(); @@ -4569,6 +5412,8 @@ const loadProjectSnapshot = async (project) => { setMeasureActive(!measureActive); } else if (action === "section") { toggleSectionPlanes(); + } else if (action === "comments") { + toggleCommentsMode(); } else if (action === "projection") { toggleProjection(); } else if (action === "share") { diff --git a/frontend/index.html b/frontend/index.html index 539ad13..d6e32ae 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -120,6 +120,12 @@ set("--section-plane-alpha", s.sectionPlaneAlpha); set("--section-plane-edge", s.sectionPlaneEdge); set("--section-plane-edge-alpha", s.sectionPlaneEdgeAlpha); + set("--comment-target-active", s.commentTargetActiveColor); + set("--comment-target-active-alpha", s.commentTargetActiveAlpha); + set("--comment-target-active-size", s.commentTargetActiveSize !== undefined ? `${s.commentTargetActiveSize}px` : undefined); + set("--comment-target-passive", s.commentTargetPassiveColor); + set("--comment-target-passive-alpha", s.commentTargetPassiveAlpha); + set("--comment-target-passive-size", s.commentTargetPassiveSize !== undefined ? `${s.commentTargetPassiveSize}px` : undefined); } catch (e) { // ignore corrupted settings } @@ -128,7 +134,7 @@ - +
@@ -247,14 +253,98 @@ + + + + + +