Add BIM model comments

This commit is contained in:
CODEX 2026-06-20 16:58:24 +03:00
parent c248217976
commit 684f85b382
6 changed files with 1849 additions and 6 deletions

View File

@ -62,6 +62,18 @@
--section-plane-alpha: 0.32; --section-plane-alpha: 0.32;
--section-plane-edge: #0d0d0d; --section-plane-edge: #0d0d0d;
--section-plane-edge-alpha: 0.8; --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-left: calc(12px + var(--tb-size, 25px) + var(--tb-gap, 10px));
--section-toolbar-bottom: 123px; --section-toolbar-bottom: 123px;
--nodedc-glass-control-bg: var(--modal-element); --nodedc-glass-control-bg: var(--modal-element);
@ -507,6 +519,273 @@ header {
box-shadow: none; 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 { .panel-section {
background: transparent; background: transparent;
border: none; border: none;
@ -1162,6 +1441,10 @@ header {
display: none; display: none;
} }
#commentModalBackdrop {
z-index: 30045;
}
.templates-panel.measurement-modal { .templates-panel.measurement-modal {
left: var(--measure-left, var(--templates-left, 72px)); left: var(--measure-left, var(--templates-left, 72px));
bottom: var(--measure-bottom, var(--templates-bottom, 12px)); bottom: var(--measure-bottom, var(--templates-bottom, 12px));
@ -1297,6 +1580,265 @@ header {
font-size: 12px; 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 { .settings-panel {
position: fixed; position: fixed;
--env-control-h: 46px; --env-control-h: 46px;

View File

@ -4,7 +4,7 @@ const SDK_URL = "./lib/dc-camera-context.es.js?v=3";
import { createLogo } from "./src/ui/logo.js"; import { createLogo } from "./src/ui/logo.js";
import { toolbarConfig, applyToolbarVars } from "./src/ui/toolbar.js"; import { toolbarConfig, applyToolbarVars } from "./src/ui/toolbar.js";
import { createTemplatesMenu } from "./src/ui/templatesMenu.js?v=2"; 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 { createMeasurementModal } from "./src/ui/measurementModal.js";
import { createGlassSelect } from "./src/ui/glassSelect.js"; import { createGlassSelect } from "./src/ui/glassSelect.js";
import { createSliderControl } from "./src/ui/controls/slider.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 templatesButton = document.querySelector('[data-action="templates"]');
const shareButton = document.querySelector('[data-action="share"]'); const shareButton = document.querySelector('[data-action="share"]');
const sectionButton = document.querySelector('[data-action="section"]'); const sectionButton = document.querySelector('[data-action="section"]');
const commentsButton = document.querySelector('[data-action="comments"]');
const sectionToolbar = document.getElementById("sectionToolbar"); const sectionToolbar = document.getElementById("sectionToolbar");
const sectionOverviewCanvas = document.getElementById("sectionPlanesOverview"); const sectionOverviewCanvas = document.getElementById("sectionPlanesOverview");
const projectionToggle = document.querySelector('[data-action="projection"]'); 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 treeContainer = document.getElementById("treeContainer");
const displayModeControl = document.getElementById("displayModeControl"); const displayModeControl = document.getElementById("displayModeControl");
const displayModeSelectHost = document.getElementById("displayModeSelect"); const displayModeSelectHost = document.getElementById("displayModeSelect");
@ -123,6 +150,7 @@ const API_BASE = "/api/projects";
const MODEL_SETTINGS_API = "/api/model-settings"; const MODEL_SETTINGS_API = "/api/model-settings";
const AUTH_SESSION_API = "/api/auth/session"; const AUTH_SESSION_API = "/api/auth/session";
const SHARES_API = "/api/shares"; const SHARES_API = "/api/shares";
const COMMENTS_API = "/api/comments";
const acceptByType = { const acceptByType = {
xkt: ".xkt", xkt: ".xkt",
@ -258,6 +286,23 @@ let activeSectionPlaneId = null;
let sectionPlaneCounter = 0; let sectionPlaneCounter = 0;
let sectionModeActive = false; let sectionModeActive = false;
let activeSectionAxis = null; 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 objectContextModal = null;
let mathUtil = null; let mathUtil = null;
let selectionState = null; // { ids, modelId, pivotCenter, baseTransforms: Map, transform } let selectionState = null; // { ids, modelId, pivotCenter, baseTransforms: Map, transform }
@ -449,11 +494,690 @@ const getShareTokenFromPath = () => {
return match ? decodeURIComponent(match[1]) : null; return match ? decodeURIComponent(match[1]) : null;
}; };
const COMMENT_TARGET_ICON = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6.5 5.5h11a2.5 2.5 0 0 1 2.5 2.5v6.25a2.5 2.5 0 0 1-2.5 2.5H12l-4.3 3.1v-3.1H6.5A2.5 2.5 0 0 1 4 14.25V8a2.5 2.5 0 0 1 2.5-2.5Z"></path>
</svg>
`;
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 = () => { const applyRuntimeMode = () => {
document.body.dataset.viewerMode = viewerMode; document.body.dataset.viewerMode = viewerMode;
if (bottomToolbar) { if (bottomToolbar) {
bottomToolbar.classList.toggle("hidden", isGuestMode()); 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) { if (shareButton) {
const canShowShare = !isGuestMode() && runtimeSessionInfo.canShare && activeModels.length > 0; const canShowShare = !isGuestMode() && runtimeSessionInfo.canShare && activeModels.length > 0;
shareButton.classList.toggle("hidden", !canShowShare); shareButton.classList.toggle("hidden", !canShowShare);
@ -1488,7 +2212,18 @@ const updatePanelsVisibility = () => {
projectNameInput.value = ""; projectNameInput.value = "";
} }
} }
if (!hasModels) {
commentsModeActive = false;
commentsCollapsed = false;
commentContextTarget = null;
activeCommentId = null;
hoverCommentId = null;
commentRecords = [];
commentSourcesLoaded.clear();
closeCommentModal();
}
updateSectionControls(); updateSectionControls();
updateCommentsVisibility();
applyRuntimeMode(); applyRuntimeMode();
}; };
@ -3298,20 +4033,67 @@ const initViewer = async () => {
const canvasEl = viewer.scene?.canvas?.canvas; const canvasEl = viewer.scene?.canvas?.canvas;
if (canvasEl) { if (canvasEl) {
let downPos = null; let downPos = null;
const clearCommentLongPress = () => {
if (commentLongPressTimer) {
window.clearTimeout(commentLongPressTimer);
commentLongPressTimer = null;
}
commentLongPressStart = null;
};
canvasEl.addEventListener("contextmenu", (event) => { canvasEl.addEventListener("contextmenu", (event) => {
event.preventDefault(); const opened = openCommentContextMenuFromEvent(event);
if (!opened) event.preventDefault();
}); });
canvasEl.addEventListener("pointerdown", (event) => { canvasEl.addEventListener("pointerdown", (event) => {
if (measureActive) { if (measureActive) {
downPos = null; downPos = null;
clearCommentLongPress();
return; return;
} }
downPos = { x: event.clientX, y: event.clientY }; 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) => { canvasEl.addEventListener("click", (event) => {
if (measureActive) { if (measureActive) {
return; return;
} }
if (suppressNextCanvasClick) {
suppressNextCanvasClick = false;
downPos = null;
return;
}
if (downPos) { if (downPos) {
const dx = Math.abs(event.clientX - downPos.x); const dx = Math.abs(event.clientX - downPos.x);
const dy = Math.abs(event.clientY - downPos.y); const dy = Math.abs(event.clientY - downPos.y);
@ -4051,6 +4833,9 @@ const loadProjectSnapshot = async (project) => {
if (key && key.startsWith("section")) { if (key && key.startsWith("section")) {
applySectionPlaneTheme(); applySectionPlaneTheme();
} }
if (key && key.startsWith("commentTarget")) {
renderCommentTargets();
}
focusCanvasAndControls(); focusCanvasAndControls();
}, },
onShow: () => { onShow: () => {
@ -4127,6 +4912,52 @@ const loadProjectSnapshot = async (project) => {
} }
inspectorCollapseButton?.addEventListener("click", () => setInspectorCollapsed(true)); inspectorCollapseButton?.addEventListener("click", () => setInspectorCollapsed(true));
inspectorRestoreButton?.addEventListener("click", () => setInspectorCollapsed(false)); 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()) { if (!isGuestMode()) {
loadProjectsList().catch(() => { loadProjectsList().catch(() => {
templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" }); templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" });
@ -4303,6 +5134,18 @@ const loadProjectSnapshot = async (project) => {
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return; 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 (e.target && ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return;
if (shareModal && !shareModal.classList.contains("hidden")) { if (shareModal && !shareModal.classList.contains("hidden")) {
closeShareModal(); closeShareModal();
@ -4569,6 +5412,8 @@ const loadProjectSnapshot = async (project) => {
setMeasureActive(!measureActive); setMeasureActive(!measureActive);
} else if (action === "section") { } else if (action === "section") {
toggleSectionPlanes(); toggleSectionPlanes();
} else if (action === "comments") {
toggleCommentsMode();
} else if (action === "projection") { } else if (action === "projection") {
toggleProjection(); toggleProjection();
} else if (action === "share") { } else if (action === "share") {

View File

@ -120,6 +120,12 @@
set("--section-plane-alpha", s.sectionPlaneAlpha); set("--section-plane-alpha", s.sectionPlaneAlpha);
set("--section-plane-edge", s.sectionPlaneEdge); set("--section-plane-edge", s.sectionPlaneEdge);
set("--section-plane-edge-alpha", s.sectionPlaneEdgeAlpha); 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) { } catch (e) {
// ignore corrupted settings // ignore corrupted settings
} }
@ -128,7 +134,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./dcViewer.css?v=22"> <link rel="stylesheet" href="./dcViewer.css?v=24">
</head> </head>
<body> <body>
<header> <header>
@ -247,14 +253,98 @@
<div class="inspector-restore-tray" id="inspectorRestoreTray" hidden> <div class="inspector-restore-tray" id="inspectorRestoreTray" hidden>
<button class="inspector-restore-button" id="inspectorRestoreButton" type="button">Инспектор</button> <button class="inspector-restore-button" id="inspectorRestoreButton" type="button">Инспектор</button>
</div> </div>
<aside class="comments-panel hidden" id="commentsPanel">
<div class="comments-panel-header" id="commentsPanelHeader">
<h3>Комментарии</h3>
<button
class="inspector-collapse-btn"
id="commentsCollapseButton"
type="button"
title="Опустить комментарии"
aria-label="Опустить комментарии"
>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4 6.25 8 10.25 12 6.25" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
</div>
<div class="comments-panel-body">
<div id="commentsList" class="comments-list"></div>
</div>
</aside>
<div class="comments-restore-tray" id="commentsRestoreTray" hidden>
<button class="inspector-restore-button" id="commentsRestoreButton" type="button">Комментарии</button>
</div>
<div id="commentTargetsLayer" class="comment-targets-layer hidden"></div>
</main> </main>
<div id="templatesPanel" class="templates-panel hidden"></div> <div id="templatesPanel" class="templates-panel hidden"></div>
<div id="settingsPanel" class="settings-panel hidden"></div> <div id="settingsPanel" class="settings-panel hidden"></div>
<div id="commentContextMenu" class="nodedc-dropdown-surface bim-context-menu hidden" role="menu" aria-label="Действия по модели">
<button class="nodedc-dropdown-option bim-context-menu__item" data-comment-menu-action="create" type="button" role="menuitem">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6.5 5.5h11a2.5 2.5 0 0 1 2.5 2.5v6.25a2.5 2.5 0 0 1-2.5 2.5H12l-4.3 3.1v-3.1H6.5A2.5 2.5 0 0 1 4 14.25V8a2.5 2.5 0 0 1 2.5-2.5Z"></path>
</svg>
<span>Комментарий</span>
</button>
</div>
<div id="measureModalBackdrop" class="modal-backdrop hidden"></div> <div id="measureModalBackdrop" class="modal-backdrop hidden"></div>
<div id="measureModal" class="templates-panel measurement-modal hidden"></div> <div id="measureModal" class="templates-panel measurement-modal hidden"></div>
<div id="objectModalBackdrop" class="modal-backdrop hidden"></div> <div id="objectModalBackdrop" class="modal-backdrop hidden"></div>
<div id="objectModal" class="templates-panel measurement-modal hidden"></div> <div id="objectModal" class="templates-panel measurement-modal hidden"></div>
<div id="commentModalBackdrop" class="modal-backdrop hidden"></div>
<div id="commentModal" class="comment-card-modal hidden" role="dialog" aria-modal="true" aria-label="Комментарий к модели">
<div class="comment-card-modal__header" id="commentModalHeader">
<div class="comment-card-modal__titles">
<div class="comment-card-modal__title">Комментарий к модели</div>
<div class="comment-card-modal__subtitle" id="commentModalSubtitle">Новый таргет</div>
</div>
<button class="modal-close inspector-collapse-btn" id="commentModalClose" type="button" aria-label="Закрыть">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
</svg>
</button>
</div>
<div class="comment-card-modal__body">
<label class="comment-field">
<span>Тема</span>
<input type="text" id="commentTitleInput" autocomplete="off" placeholder="Тема комментария">
</label>
<label class="comment-field">
<span>Описание</span>
<textarea id="commentBodyInput" rows="5" placeholder="Описание комментария"></textarea>
</label>
<div class="comment-attachments">
<div class="comment-attachments__head">
<span>Картинки</span>
<button type="button" class="comment-attach-button" id="commentAttachButton">Добавить</button>
<input type="file" id="commentAttachmentInput" accept="image/*" multiple hidden>
</div>
<div class="comment-attachment-grid" id="commentAttachmentGrid"></div>
</div>
<div class="comment-thread">
<div class="comment-thread__title">Комментарии</div>
<div class="comment-thread__list" id="commentThreadList"></div>
<label class="comment-field comment-field--reply">
<span>Новый комментарий</span>
<textarea id="commentReplyInput" rows="3" placeholder="Оставить комментарий"></textarea>
</label>
<div class="comment-attachments comment-attachments--reply">
<div class="comment-attachments__head">
<span>Картинки к ответу</span>
<button type="button" class="comment-attach-button" id="commentReplyAttachButton">Добавить</button>
<input type="file" id="commentReplyAttachmentInput" accept="image/*" multiple hidden>
</div>
<div class="comment-attachment-grid" id="commentReplyAttachmentGrid"></div>
</div>
</div>
<div class="error-text" id="commentModalError"></div>
</div>
<div class="comment-card-modal__footer">
<button class="secondary-btn" id="commentCancelButton" type="button">Отменить</button>
<button class="primary-btn" id="commentApplyButton" type="button">Применить</button>
</div>
</div>
<div id="saveProjectBackdrop" class="modal-backdrop hidden"></div> <div id="saveProjectBackdrop" class="modal-backdrop hidden"></div>
<div id="saveProjectModal" class="save-project-modal hidden"> <div id="saveProjectModal" class="save-project-modal hidden">
<div class="modal-header"> <div class="modal-header">
@ -339,6 +429,11 @@
<path d="M6.25 4.5 17.75 19.5"></path> <path d="M6.25 4.5 17.75 19.5"></path>
</svg> </svg>
</button> </button>
<button class="tb-btn" data-action="comments" title="Комментарии" aria-label="Комментарии">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6.5 5.5h11a2.5 2.5 0 0 1 2.5 2.5v6.25a2.5 2.5 0 0 1-2.5 2.5H12l-4.3 3.1v-3.1H6.5A2.5 2.5 0 0 1 4 14.25V8a2.5 2.5 0 0 1 2.5-2.5Z"></path>
</svg>
</button>
<button class="tb-btn" data-action="projection" title="Перспектива / аксонометрия">A</button> <button class="tb-btn" data-action="projection" title="Перспектива / аксонометрия">A</button>
<button class="tb-btn" data-action="share" title="Поделиться моделью" aria-label="Поделиться моделью"> <button class="tb-btn" data-action="share" title="Поделиться моделью" aria-label="Поделиться моделью">
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
@ -399,6 +494,6 @@
<input id="fileInput" class="hidden" type="file" <input id="fileInput" class="hidden" type="file"
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl"> accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
<script type="module" src="./dcViewer.js?v=26"></script> <script type="module" src="./dcViewer.js?v=28"></script>
</body> </body>
</html> </html>

View File

@ -2,7 +2,7 @@ import { createSliderControl } from "./controls/slider.js";
import { createColorControl } from "./controls/color.js"; import { createColorControl } from "./controls/color.js";
const defaultSettings = { const defaultSettings = {
themeVersion: "v5-drone-lime", themeVersion: "v6-comments-targets",
bgOuter: "#0f0f0f", bgOuter: "#0f0f0f",
canvasTop: "#1c1c1c", canvasTop: "#1c1c1c",
canvasBottom: "#1c1c1c", canvasBottom: "#1c1c1c",
@ -58,6 +58,12 @@ const defaultSettings = {
sectionPlaneAlpha: 0.32, sectionPlaneAlpha: 0.32,
sectionPlaneEdge: "#0d0d0d", sectionPlaneEdge: "#0d0d0d",
sectionPlaneEdgeAlpha: 0.8, sectionPlaneEdgeAlpha: 0.8,
commentTargetActiveColor: "#c4ff67",
commentTargetActiveAlpha: 1,
commentTargetActiveSize: 30,
commentTargetPassiveColor: "#f5f5fa",
commentTargetPassiveAlpha: 0.58,
commentTargetPassiveSize: 22,
}; };
const STORAGE_KEY = "bimdc-settings-v3"; const STORAGE_KEY = "bimdc-settings-v3";
@ -159,6 +165,12 @@ const applySettings = (s) => {
root.style.setProperty("--section-plane-alpha", s.sectionPlaneAlpha); root.style.setProperty("--section-plane-alpha", s.sectionPlaneAlpha);
root.style.setProperty("--section-plane-edge", s.sectionPlaneEdge); root.style.setProperty("--section-plane-edge", s.sectionPlaneEdge);
root.style.setProperty("--section-plane-edge-alpha", s.sectionPlaneEdgeAlpha); root.style.setProperty("--section-plane-edge-alpha", s.sectionPlaneEdgeAlpha);
root.style.setProperty("--comment-target-active", s.commentTargetActiveColor);
root.style.setProperty("--comment-target-active-alpha", s.commentTargetActiveAlpha);
root.style.setProperty("--comment-target-active-size", `${s.commentTargetActiveSize}px`);
root.style.setProperty("--comment-target-passive", s.commentTargetPassiveColor);
root.style.setProperty("--comment-target-passive-alpha", s.commentTargetPassiveAlpha);
root.style.setProperty("--comment-target-passive-size", `${s.commentTargetPassiveSize}px`);
// подсветка объектов // подсветка объектов
root.style.setProperty("--highlight-color", s.highlight); root.style.setProperty("--highlight-color", s.highlight);
}; };
@ -458,6 +470,59 @@ export function createSettingsMenu({ panel, controls = {}, onChange = null, onSh
}); });
panel.appendChild(sectionCut); panel.appendChild(sectionCut);
const commentTargets = section("Таргеты комментариев");
addColor(commentTargets, "Активный: цвет", "commentTargetActiveColor");
addSettingsSlider({
parent: commentTargets,
label: "Активный: прозрачность",
value: Math.round(Math.max(0, Math.min(1, Number(state.commentTargetActiveAlpha ?? 1))) * 100),
min: 0,
max: 100,
step: 5,
formatValue: (val) => `${Math.round(val)}%`,
onChange: (val) => {
setVal("commentTargetActiveAlpha", Math.max(0, Math.min(1, Number(val) / 100)));
},
});
addSettingsSlider({
parent: commentTargets,
label: "Активный: размер",
value: Math.max(14, Math.min(64, Number(state.commentTargetActiveSize ?? 30))),
min: 14,
max: 64,
step: 1,
formatValue: (val) => `${Math.round(val)}px`,
onChange: (val) => {
setVal("commentTargetActiveSize", Math.max(14, Math.min(64, Math.round(Number(val)))));
},
});
addColor(commentTargets, "Пассивный: цвет", "commentTargetPassiveColor");
addSettingsSlider({
parent: commentTargets,
label: "Пассивный: прозрачность",
value: Math.round(Math.max(0, Math.min(1, Number(state.commentTargetPassiveAlpha ?? 0.58))) * 100),
min: 0,
max: 100,
step: 5,
formatValue: (val) => `${Math.round(val)}%`,
onChange: (val) => {
setVal("commentTargetPassiveAlpha", Math.max(0, Math.min(1, Number(val) / 100)));
},
});
addSettingsSlider({
parent: commentTargets,
label: "Пассивный: размер",
value: Math.max(12, Math.min(48, Number(state.commentTargetPassiveSize ?? 22))),
min: 12,
max: 48,
step: 1,
formatValue: (val) => `${Math.round(val)}px`,
onChange: (val) => {
setVal("commentTargetPassiveSize", Math.max(12, Math.min(48, Math.round(Number(val)))));
},
});
panel.appendChild(commentTargets);
const measure = section("Линейка"); const measure = section("Линейка");
addColor(measure, "Линия/бордер", "measureLine"); addColor(measure, "Линия/бордер", "measureLine");
addColor(measure, "Плашка", "measureLabel"); addColor(measure, "Плашка", "measureLabel");

View File

@ -1,7 +1,7 @@
// Базовые цвета/настройки проекта. При очистке localStorage эти значения остаются. // Базовые цвета/настройки проекта. При очистке localStorage эти значения остаются.
// Чтобы изменить палитру по умолчанию — правь этот объект и закоммить изменения. // Чтобы изменить палитру по умолчанию — правь этот объект и закоммить изменения.
const PROJECT_THEME = { const PROJECT_THEME = {
themeVersion: "v5-drone-lime", themeVersion: "v6-comments-targets",
bgOuter: "#0f0f0f", bgOuter: "#0f0f0f",
canvasTop: "#1c1c1c", canvasTop: "#1c1c1c",
canvasBottom: "#1c1c1c", canvasBottom: "#1c1c1c",
@ -57,6 +57,12 @@ const PROJECT_THEME = {
sectionPlaneAlpha: 0.32, sectionPlaneAlpha: 0.32,
sectionPlaneEdge: "#0d0d0d", sectionPlaneEdge: "#0d0d0d",
sectionPlaneEdgeAlpha: 0.8, sectionPlaneEdgeAlpha: 0.8,
commentTargetActiveColor: "#c4ff67",
commentTargetActiveAlpha: 1,
commentTargetActiveSize: 30,
commentTargetPassiveColor: "#f5f5fa",
commentTargetPassiveAlpha: 0.58,
commentTargetPassiveSize: 22,
}; };
if (typeof window !== "undefined") { if (typeof window !== "undefined") {

View File

@ -23,9 +23,11 @@ const DATA_DIR = path.join(DATA_ROOT, "projects");
const UPLOADS_DIR = path.join(DATA_ROOT, "uploads"); const UPLOADS_DIR = path.join(DATA_ROOT, "uploads");
const SHARES_DIR = path.join(DATA_ROOT, "shares"); const SHARES_DIR = path.join(DATA_ROOT, "shares");
const MODELS_DIR = path.join(DATA_ROOT, "models"); const MODELS_DIR = path.join(DATA_ROOT, "models");
const COMMENTS_DIR = path.join(DATA_ROOT, "comments");
const INDEX_FILE = path.join(DATA_DIR, "index.json"); const INDEX_FILE = path.join(DATA_DIR, "index.json");
const SHARES_INDEX_FILE = path.join(SHARES_DIR, "index.json"); const SHARES_INDEX_FILE = path.join(SHARES_DIR, "index.json");
const MODEL_REGISTRY_FILE = path.join(MODELS_DIR, "registry.json"); const MODEL_REGISTRY_FILE = path.join(MODELS_DIR, "registry.json");
const COMMENTS_INDEX_FILE = path.join(COMMENTS_DIR, "index.json");
const FRONTEND_DIST = path.join(__dirname, "..", "frontend", "dist"); const FRONTEND_DIST = path.join(__dirname, "..", "frontend", "dist");
const FRONTEND_ROOT = path.join(__dirname, "..", "frontend"); const FRONTEND_ROOT = path.join(__dirname, "..", "frontend");
const PORT = parseInt(process.env.PORT, 10) || 8080; const PORT = parseInt(process.env.PORT, 10) || 8080;
@ -98,6 +100,7 @@ const ensureStorage = async () => {
await fs.mkdir(UPLOADS_DIR, {recursive: true}); await fs.mkdir(UPLOADS_DIR, {recursive: true});
await fs.mkdir(SHARES_DIR, {recursive: true}); await fs.mkdir(SHARES_DIR, {recursive: true});
await fs.mkdir(MODELS_DIR, {recursive: true}); await fs.mkdir(MODELS_DIR, {recursive: true});
await fs.mkdir(COMMENTS_DIR, {recursive: true});
try { try {
await fs.access(INDEX_FILE); await fs.access(INDEX_FILE);
} catch (err) { } catch (err) {
@ -113,6 +116,11 @@ const ensureStorage = async () => {
} catch (err) { } catch (err) {
await fs.writeFile(MODEL_REGISTRY_FILE, JSON.stringify(createEmptyModelRegistry(), null, 2), "utf8"); await fs.writeFile(MODEL_REGISTRY_FILE, JSON.stringify(createEmptyModelRegistry(), null, 2), "utf8");
} }
try {
await fs.access(COMMENTS_INDEX_FILE);
} catch (err) {
await fs.writeFile(COMMENTS_INDEX_FILE, "[]", "utf8");
}
}; };
const readIndex = async () => { const readIndex = async () => {
@ -145,6 +153,27 @@ const writeSharesIndex = async (items) => {
await fs.writeFile(SHARES_INDEX_FILE, JSON.stringify(items, null, 2), "utf8"); await fs.writeFile(SHARES_INDEX_FILE, JSON.stringify(items, null, 2), "utf8");
}; };
const readCommentsIndex = async () => {
try {
const raw = await fs.readFile(COMMENTS_INDEX_FILE, "utf8");
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (err) {
if (err?.code === "ENOENT") {
await fs.mkdir(COMMENTS_DIR, {recursive: true});
await fs.writeFile(COMMENTS_INDEX_FILE, "[]", "utf8");
return [];
}
console.error("[server] failed to read comments index.json", err);
return [];
}
};
const writeCommentsIndex = async (items) => {
await fs.mkdir(COMMENTS_DIR, {recursive: true});
await fs.writeFile(COMMENTS_INDEX_FILE, JSON.stringify(items, null, 2), "utf8");
};
const createEmptyModelRegistry = () => ({ const createEmptyModelRegistry = () => ({
version: 1, version: 1,
assets: [], assets: [],
@ -839,6 +868,9 @@ const requestNeedsBimSession = (req, url) => {
if (url.pathname.startsWith("/api/models")) { if (url.pathname.startsWith("/api/models")) {
return true; return true;
} }
if (url.pathname.startsWith("/api/comments")) {
return true;
}
if (url.pathname.startsWith("/api/projects")) { if (url.pathname.startsWith("/api/projects")) {
return true; return true;
} }
@ -1522,6 +1554,250 @@ const handleGetShare = async (req, res, token) => {
}); });
}; };
const sanitizeCommentText = (value, maxLength = 4000) => {
if (typeof value !== "string") {
return "";
}
return value.trim().slice(0, maxLength);
};
const normalizeCommentWorldPos = (value) => {
if (!Array.isArray(value) || value.length !== 3) {
return null;
}
const next = value.map((item) => Number(item));
return next.every((item) => Number.isFinite(item)) ? next : null;
};
const sanitizeCommentAttachments = (value) => {
if (!Array.isArray(value)) {
return [];
}
return value.slice(0, 8).flatMap((item) => {
if (!item || typeof item !== "object") {
return [];
}
const dataUrl = typeof item.dataUrl === "string" ? item.dataUrl : "";
if (!/^data:image\/(png|jpe?g|webp|gif);base64,/i.test(dataUrl)) {
return [];
}
if (dataUrl.length > 4_500_000) {
return [];
}
return [{
id: stringOrNull(item.id) || `att_${randomBase64Url(8)}`,
name: sanitizeFilename(item.name || "image", "image"),
type: stringOrNull(item.type) || "image",
size: Number.isFinite(Number(item.size)) ? Number(item.size) : null,
dataUrl
}];
});
};
const resolveCommentSource = (payload = {}) => {
const identity = resolveAssetIdentityFromPayload(payload);
const rawSrc = stringOrNull(payload.src) ||
stringOrNull(payload.url) ||
stringOrNull(payload.sourceSrc) ||
stringOrNull(payload.settingsSrc) ||
stringOrNull(payload.artifactSrc);
const sourceSrc = normalizeUploadSrcValue(rawSrc) || rawSrc || null;
if (identity) {
return {
sourceKey: identity.assetKey,
projectId: identity.projectId,
assetId: identity.assetId,
sourceSrc
};
}
if (!sourceSrc) {
return null;
}
return {
sourceKey: `src:${shortHash(sourceSrc, 24)}`,
projectId: safeProjectId(payload.projectId) || null,
assetId: safeAssetId(payload.assetId) || null,
sourceSrc
};
};
const buildCommentMessage = (payload, user, fallbackBody = "") => {
const source = typeof payload === "string" ? {body: payload} : (payload || {});
const body = sanitizeCommentText(source.body ?? source.text ?? source.message ?? fallbackBody);
const attachments = sanitizeCommentAttachments(source.attachments);
if (!body && !attachments.length) {
return null;
}
const now = nowIso();
return {
id: `msg_${randomBase64Url(10)}`,
body,
attachments,
createdAt: now,
updatedAt: now,
createdBy: summarizeRegistryUser(user)
};
};
const publicComment = (comment) => ({
id: comment.id,
sourceKey: comment.sourceKey,
sourceSrc: comment.sourceSrc || null,
projectId: comment.projectId || null,
assetId: comment.assetId || null,
modelId: comment.modelId || null,
objectId: comment.objectId || null,
title: comment.title || "",
body: comment.body || "",
worldPos: comment.worldPos || null,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
createdBy: comment.createdBy || null,
messages: Array.isArray(comment.messages) ? comment.messages : []
});
const handleGetComments = async (req, res, url) => {
const user = requireModelRegistryUser(req, res);
if (!user) {
return;
}
const source = resolveCommentSource(Object.fromEntries(url.searchParams.entries()));
if (!source?.sourceKey) {
sendJSON(res, 200, {ok: true, user: summarizeRegistryUser(user), comments: []});
return;
}
const items = await readCommentsIndex();
const comments = items
.filter((item) => item?.sourceKey === source.sourceKey && !item.deletedAt)
.map(publicComment)
.toSorted((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
sendJSON(res, 200, {
ok: true,
user: summarizeRegistryUser(user),
source,
comments
});
};
const handleCreateComment = async (req, res) => {
const user = requireModelRegistryUser(req, res);
if (!user) {
return;
}
let payload = {};
try {
payload = await parseJSONBody(req);
} catch (err) {
sendText(res, 400, err.message || "Invalid JSON");
return;
}
const source = resolveCommentSource(payload);
const worldPos = normalizeCommentWorldPos(payload?.worldPos);
const title = sanitizeCommentText(payload?.title, 180);
const body = sanitizeCommentText(payload?.body ?? payload?.description, 8000);
if (!source?.sourceKey) {
sendText(res, 400, "Comment source is required");
return;
}
if (!worldPos) {
sendText(res, 400, "Comment worldPos is required");
return;
}
if (!title && !body) {
sendText(res, 400, "Comment title or body is required");
return;
}
const now = nowIso();
const firstMessage = buildCommentMessage({
body,
attachments: payload?.attachments
}, user);
const comment = {
id: `cmt_${randomBase64Url(14)}`,
sourceKey: source.sourceKey,
sourceSrc: source.sourceSrc || null,
projectId: source.projectId || null,
assetId: source.assetId || null,
modelId: stringOrNull(payload?.modelId),
objectId: stringOrNull(payload?.objectId),
title: title || "Комментарий",
body,
worldPos,
createdAt: now,
updatedAt: now,
createdBy: summarizeRegistryUser(user),
messages: firstMessage ? [firstMessage] : []
};
const items = await readCommentsIndex();
items.unshift(comment);
await writeCommentsIndex(items);
sendJSON(res, 201, {ok: true, comment: publicComment(comment)});
};
const handlePatchComment = async (req, res, commentId) => {
const user = requireModelRegistryUser(req, res);
if (!user) {
return;
}
const safeId = stringOrNull(commentId);
if (!safeId) {
sendText(res, 400, "Invalid comment id");
return;
}
let payload = {};
try {
payload = await parseJSONBody(req);
} catch (err) {
sendText(res, 400, err.message || "Invalid JSON");
return;
}
const items = await readCommentsIndex();
const index = items.findIndex((item) => item?.id === safeId && !item.deletedAt);
if (index < 0) {
sendText(res, 404, "Comment not found");
return;
}
const comment = items[index];
const isOwner = comment?.createdBy?.id && comment.createdBy.id === user.id;
const now = nowIso();
if (payload?.delete === true) {
if (!isOwner) {
sendJSON(res, 403, {ok: false, error: "comment_owner_required"});
return;
}
comment.deletedAt = now;
comment.updatedAt = now;
await writeCommentsIndex(items);
sendJSON(res, 200, {ok: true, deleted: true, id: safeId});
return;
}
const hasTitle = Object.prototype.hasOwnProperty.call(payload, "title");
const hasBody = Object.prototype.hasOwnProperty.call(payload, "body");
const nextTitle = hasTitle ? sanitizeCommentText(payload.title, 180) || comment.title || "Комментарий" : comment.title;
const nextBody = hasBody ? sanitizeCommentText(payload.body, 8000) : comment.body;
const titleChanged = hasTitle && nextTitle !== comment.title;
const bodyChanged = hasBody && nextBody !== comment.body;
if ((titleChanged || bodyChanged) && !isOwner) {
sendJSON(res, 403, {ok: false, error: "comment_owner_required"});
return;
}
if (titleChanged) {
comment.title = nextTitle;
}
if (bodyChanged) {
comment.body = nextBody;
}
const nextMessage = buildCommentMessage(payload?.message || null, user);
if (nextMessage) {
comment.messages = Array.isArray(comment.messages) ? comment.messages : [];
comment.messages.push(nextMessage);
}
comment.updatedAt = now;
items[index] = comment;
await writeCommentsIndex(items);
sendJSON(res, 200, {ok: true, comment: publicComment(comment)});
};
const requireModelRegistryUser = (req, res) => { const requireModelRegistryUser = (req, res) => {
const user = getRequestRegistryUser(req); const user = getRequestRegistryUser(req);
if (!user) { if (!user) {
@ -2709,6 +2985,20 @@ const requestHandler = async (req, res) => {
return handleCreateShare(req, res); return handleCreateShare(req, res);
} }
if (url.pathname.startsWith("/api/comments")) {
if (req.method === "GET" && url.pathname === "/api/comments") {
return handleGetComments(req, res, url);
}
if (req.method === "POST" && url.pathname === "/api/comments") {
return handleCreateComment(req, res);
}
if (req.method === "PATCH" && /^\/api\/comments\/[^/]+\/?$/.test(url.pathname)) {
return handlePatchComment(req, res, url.pathname.split("/")[3]);
}
sendText(res, 405, "Method Not Allowed");
return;
}
if (url.pathname.startsWith("/api/models")) { if (url.pathname.startsWith("/api/models")) {
if (req.method === "GET" && url.pathname === "/api/models") { if (req.method === "GET" && url.pathname === "/api/models") {
return handleGetModels(req, res); return handleGetModels(req, res);