diff --git a/frontend/dcViewer.css b/frontend/dcViewer.css index 5abf746..d8cef46 100644 --- a/frontend/dcViewer.css +++ b/frontend/dcViewer.css @@ -1583,6 +1583,7 @@ header { .comment-card-modal { position: fixed; --env-control-h: 42px; + --ops-comment-accent: var(--comment-target-active, #c4ff67); --ops-detail-bg: rgba(18, 18, 20, 0.96); --ops-detail-surface: rgba(28, 28, 31, 0.76); --ops-detail-surface-strong: rgba(32, 32, 35, 0.92); @@ -1607,6 +1608,15 @@ header { -webkit-backdrop-filter: blur(30px) saturate(1.08); } +.comment-card-modal.is-expanded { + top: 16px !important; + left: 16px !important; + width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + height: calc(100vh - 32px); + border-radius: 26px; +} + .comment-card-modal.hidden { display: none; } @@ -1614,7 +1624,7 @@ header { .comment-card-modal__header { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-end; min-height: 64px; padding: 20px 28px 8px; cursor: grab; @@ -1794,6 +1804,14 @@ header { color: rgba(255, 255, 255, 0.95); } +.bim-subitem-menu { + z-index: 30070; + display: grid; + gap: 0.18rem; + min-width: 17.25rem; + max-width: min(22rem, calc(100vw - 24px)); +} + .ops-comment-subitems, .comment-attachments, .comment-thread { @@ -1830,13 +1848,19 @@ header { background: rgba(255, 255, 255, 0.026); } +.ops-comment-subitem[data-kind="text"] { + grid-template-columns: minmax(0, 1fr) 28px; + align-items: start; +} + .ops-comment-subitem input[type="checkbox"] { width: 16px; height: 16px; - accent-color: var(--accent); + accent-color: var(--ops-comment-accent); } -.ops-comment-subitem input[type="text"] { +.ops-comment-subitem input[type="text"], +.ops-comment-subitem textarea { min-width: 0; border: 0; background: transparent; @@ -1846,6 +1870,12 @@ header { outline: none; } +.ops-comment-subitem textarea { + min-height: 84px; + line-height: 1.45; + resize: vertical; +} + .ops-comment-subitem button { display: grid; place-items: center; @@ -1877,12 +1907,12 @@ header { } .ops-detail-dropzone.drag-over { - background: color-mix(in srgb, var(--accent) 18%, rgba(255, 255, 255, 0.04)); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 44%, transparent); + background: color-mix(in srgb, var(--ops-comment-accent) 18%, rgba(255, 255, 255, 0.04)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ops-comment-accent) 44%, transparent); } .ops-detail-dropzone__icon { - color: var(--accent); + color: var(--ops-comment-accent); } .ops-detail-dropzone__copy { @@ -1909,7 +1939,7 @@ header { border: 0; border-radius: 13px; background: transparent; - color: var(--accent); + color: var(--ops-comment-accent); cursor: pointer; font: inherit; font-size: 13px; @@ -2121,8 +2151,8 @@ header { .comment-card-modal__footer { position: sticky; bottom: 0; - display: flex; - justify-content: flex-end; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; padding: 14px 32px 18px; background: var(--ops-detail-bg); @@ -2131,7 +2161,17 @@ header { .comment-card-modal__footer .primary-btn, .comment-card-modal__footer .secondary-btn { - min-width: 148px; + min-width: 0; +} + +.comment-card-modal .primary-btn { + background: var(--ops-comment-accent); + color: var(--toolbar-bg-active-contrast, #15170f); + box-shadow: 0 8px 24px color-mix(in srgb, var(--ops-comment-accent) 24%, transparent); +} + +.comment-card-modal .primary-btn:not(:disabled):hover { + box-shadow: 0 10px 30px color-mix(in srgb, var(--ops-comment-accent) 30%, transparent); } .comment-card-modal .error-text { diff --git a/frontend/dcViewer.js b/frontend/dcViewer.js index 3a64a24..5455ce7 100644 --- a/frontend/dcViewer.js +++ b/frontend/dcViewer.js @@ -56,6 +56,7 @@ const commentModalBackdrop = document.getElementById("commentModalBackdrop"); const commentModal = document.getElementById("commentModal"); const commentModalHeader = document.getElementById("commentModalHeader"); const commentModalClose = document.getElementById("commentModalClose"); +const commentModalExpandButton = document.getElementById("commentModalExpandButton"); const commentModalSubtitle = document.getElementById("commentModalSubtitle"); const commentModalEditedMeta = document.getElementById("commentModalEditedMeta"); const commentTitleInput = document.getElementById("commentTitleInput"); @@ -67,6 +68,7 @@ const commentAttachmentGrid = document.getElementById("commentAttachmentGrid"); const commentSubitemsSection = document.getElementById("commentSubitemsSection"); const commentSubitemsList = document.getElementById("commentSubitemsList"); const commentAddSubitemButton = document.getElementById("commentAddSubitemButton"); +const commentSubitemMenu = document.getElementById("commentSubitemMenu"); const commentThreadList = document.getElementById("commentThreadList"); const commentReplyInput = document.getElementById("commentReplyInput"); const commentReplyAttachButton = document.getElementById("commentReplyAttachButton"); @@ -75,6 +77,7 @@ const commentReplyAttachmentGrid = document.getElementById("commentReplyAttachme const commentModalError = document.getElementById("commentModalError"); const commentCancelButton = document.getElementById("commentCancelButton"); const commentApplyButton = document.getElementById("commentApplyButton"); +const commentSaveButton = document.getElementById("commentSaveButton"); const commentApplyInlineButton = document.getElementById("commentApplyInlineButton"); const treeContainer = document.getElementById("treeContainer"); const displayModeControl = document.getElementById("displayModeControl"); @@ -299,6 +302,7 @@ let commentSourcesLoaded = new Set(); let commentContextTarget = null; let commentModalMode = "create"; let commentModalCommentId = null; +let commentModalExpanded = false; let activeCommentId = null; let hoverCommentId = null; let commentDraftAttachments = []; @@ -1058,27 +1062,94 @@ const renderModalAttachmentInputs = () => { }); }; +const setCommentModalExpanded = (expanded) => { + commentModalExpanded = !!expanded; + commentModal?.classList.toggle("is-expanded", commentModalExpanded); + if (commentModalExpandButton) { + commentModalExpandButton.setAttribute("aria-label", commentModalExpanded ? "Свернуть" : "Развернуть"); + commentModalExpandButton.title = commentModalExpanded ? "Свернуть" : "Развернуть"; + } +}; + +const hideCommentSubitemMenu = () => { + commentSubitemMenu?.classList.add("hidden"); +}; + +const showCommentSubitemMenu = () => { + if (!commentSubitemMenu || !commentAddSubitemButton) return; + commentSubitemMenu.classList.remove("hidden"); + const buttonRect = commentAddSubitemButton.getBoundingClientRect(); + const width = commentSubitemMenu.offsetWidth || 276; + const height = commentSubitemMenu.offsetHeight || 94; + const x = Math.max(8, Math.min(window.innerWidth - width - 8, buttonRect.left + (buttonRect.width - width) / 2)); + const y = Math.max(8, Math.min(window.innerHeight - height - 8, buttonRect.bottom + 8)); + commentSubitemMenu.style.left = `${x}px`; + commentSubitemMenu.style.top = `${y}px`; +}; + +const toggleCommentSubitemMenu = () => { + if (!commentSubitemMenu) return; + if (commentSubitemMenu.classList.contains("hidden")) { + showCommentSubitemMenu(); + } else { + hideCommentSubitemMenu(); + } +}; + +const addCommentSubitem = (kind) => { + const nextKind = kind === "text" ? "text" : "checker"; + commentDraftSubitems.push({ + id: `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`, + kind: nextKind, + title: "", + body: "", + done: false, + }); + renderCommentSubitems(); + window.requestAnimationFrame(() => { + const selector = nextKind === "text" + ? ".ops-comment-subitem[data-kind='text'] textarea" + : ".ops-comment-subitem[data-kind='checker'] input[type='text']"; + const inputs = commentSubitemsList?.querySelectorAll?.(selector); + inputs?.[inputs.length - 1]?.focus?.(); + }); +}; + const renderCommentSubitems = () => { if (!commentSubitemsList || !commentSubitemsSection) return; commentSubitemsList.replaceChildren(); const subitems = Array.isArray(commentDraftSubitems) ? commentDraftSubitems : []; commentSubitemsSection.classList.toggle("hidden", !subitems.length); subitems.forEach((subitem, index) => { - const row = document.createElement("label"); + const kind = subitem.kind === "text" ? "text" : "checker"; + const row = document.createElement(kind === "text" ? "div" : "label"); row.className = "ops-comment-subitem"; - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = !!subitem.done; - checkbox.addEventListener("change", () => { - commentDraftSubitems[index] = { ...commentDraftSubitems[index], done: checkbox.checked }; - }); - const input = document.createElement("input"); - input.type = "text"; - input.value = subitem.title || ""; - input.placeholder = "Новый подэлемент"; - input.addEventListener("input", () => { - commentDraftSubitems[index] = { ...commentDraftSubitems[index], title: input.value }; - }); + row.dataset.kind = kind; + let editor = null; + if (kind === "text") { + editor = document.createElement("textarea"); + editor.rows = 3; + editor.value = subitem.body || subitem.title || ""; + editor.placeholder = "Текстовый блок"; + editor.addEventListener("input", () => { + commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, body: editor.value, title: editor.value }; + }); + } else { + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = !!subitem.done; + checkbox.addEventListener("change", () => { + commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, done: checkbox.checked }; + }); + editor = document.createElement("input"); + editor.type = "text"; + editor.value = subitem.title || subitem.body || ""; + editor.placeholder = "Новый чекер"; + editor.addEventListener("input", () => { + commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, title: editor.value }; + }); + row.appendChild(checkbox); + } const remove = document.createElement("button"); remove.type = "button"; remove.setAttribute("aria-label", "Удалить подэлемент"); @@ -1087,13 +1158,14 @@ const renderCommentSubitems = () => { commentDraftSubitems.splice(index, 1); renderCommentSubitems(); }); - row.append(checkbox, input, remove); + row.append(editor, remove); commentSubitemsList.appendChild(row); }); }; const openCommentModalForTarget = () => { if (!commentContextTarget || !commentModal) return; + hideCommentSubitemMenu(); commentsModeActive = true; commentsCollapsed = false; commentModalMode = "create"; @@ -1124,6 +1196,8 @@ const openCommentModalForTarget = () => { const openCommentModalForComment = (commentId) => { const comment = commentRecords.find((item) => item.id === commentId); if (!comment || !commentModal) return; + hideCommentSubitemMenu(); + commentContextTarget = null; commentModalMode = "edit"; commentModalCommentId = comment.id; activeCommentId = comment.id; @@ -1150,8 +1224,10 @@ const openCommentModalForComment = (commentId) => { }; const closeCommentModal = () => { + hideCommentSubitemMenu(); commentModalBackdrop?.classList.add("hidden"); commentModal?.classList.add("hidden"); + setCommentModalExpanded(false); if (commentModalMode === "create") { commentContextTarget = null; } @@ -1165,12 +1241,18 @@ const closeCommentModal = () => { const normalizedSubitemsDraft = () => ( (commentDraftSubitems || []) - .map((item) => ({ - id: item.id || `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`, - title: String(item.title || "").trim(), - done: !!item.done, - })) - .filter((item) => item.title) + .map((item) => { + const kind = item.kind === "text" ? "text" : "checker"; + const content = String(kind === "text" ? (item.body || item.title || "") : (item.title || item.body || "")).trim(); + return { + id: item.id || `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`, + kind, + title: content, + body: kind === "text" ? content : "", + done: kind === "checker" && item.done === true, + }; + }) + .filter((item) => item.title || item.body) ); const buildCommentPayload = ({ title, body, replyBody } = {}) => { @@ -1184,7 +1266,32 @@ const buildCommentPayload = ({ title, body, replyBody } = {}) => { }; }; -const applyCommentModal = async () => { +const syncCommentModalAfterSave = (comment) => { + if (!comment) return; + commentModalMode = "edit"; + commentModalCommentId = comment.id; + commentContextTarget = null; + activeCommentId = comment.id; + commentTitleInput && (commentTitleInput.value = comment.title || ""); + commentBodyInput && (commentBodyInput.value = comment.body || ""); + commentReplyInput && (commentReplyInput.value = ""); + commentDraftAttachments = []; + commentReplyAttachments = []; + commentDraftSubitems = Array.isArray(comment.subitems) + ? comment.subitems.map((item) => ({ ...item })) + : []; + if (commentModalSubtitle) { + commentModalSubtitle.textContent = comment.id || "BIM-COMMENT"; + } + if (commentModalEditedMeta) { + commentModalEditedMeta.textContent = `Последнее редактирование: ${formatDateTime(comment.updatedAt || comment.createdAt)}`; + } + renderModalAttachmentInputs(); + renderCommentSubitems(); + renderCommentThread(comment); +}; + +const applyCommentModal = async ({ closeOnSave = false } = {}) => { if (isGuestMode()) return; const title = commentTitleInput?.value?.trim() || ""; const body = commentBodyInput?.value?.trim() || ""; @@ -1197,6 +1304,10 @@ const applyCommentModal = async () => { commentApplyButton.disabled = true; commentApplyButton.textContent = "Сохранение..."; } + if (commentSaveButton) { + commentSaveButton.disabled = true; + commentSaveButton.textContent = "Сохранение..."; + } if (commentApplyInlineButton) { commentApplyInlineButton.disabled = true; } @@ -1211,18 +1322,23 @@ const applyCommentModal = async () => { } else if (commentModalCommentId) { const original = commentRecords.find((item) => item.id === commentModalCommentId); const subitems = normalizedSubitemsDraft(); + const messageAttachments = [...commentDraftAttachments, ...commentReplyAttachments].slice(0, 8); const patchPayload = {}; if (!original || title !== (original.title || "")) patchPayload.title = title; if (!original || body !== (original.body || "")) patchPayload.body = body; if (JSON.stringify(subitems) !== JSON.stringify(original?.subitems || [])) patchPayload.subitems = subitems; - if (replyBody || commentReplyAttachments.length) { + if (replyBody || messageAttachments.length) { patchPayload.message = { body: replyBody, - attachments: commentReplyAttachments, + attachments: messageAttachments, }; } if (!Object.keys(patchPayload).length) { - closeCommentModal(); + if (closeOnSave) { + closeCommentModal(); + } else { + setStatus("Изменений нет"); + } return; } payload = await fetchJSON(`${COMMENTS_API}/${encodeURIComponent(commentModalCommentId)}`, { @@ -1238,7 +1354,10 @@ const applyCommentModal = async () => { commentContextTarget = null; commentsModeActive = true; commentsCollapsed = false; - closeCommentModal(); + syncCommentModalAfterSave(saved); + if (closeOnSave) { + closeCommentModal(); + } updateCommentsVisibility(); setStatus("Комментарий сохранён"); } @@ -1250,6 +1369,10 @@ const applyCommentModal = async () => { commentApplyButton.disabled = false; commentApplyButton.textContent = "Применить"; } + if (commentSaveButton) { + commentSaveButton.disabled = false; + commentSaveButton.textContent = "Сохранить"; + } if (commentApplyInlineButton) { commentApplyInlineButton.disabled = false; } @@ -5076,22 +5199,32 @@ const loadProjectSnapshot = async (project) => { }); commentModalClose?.addEventListener("click", closeCommentModal); commentCancelButton?.addEventListener("click", closeCommentModal); - commentApplyButton?.addEventListener("click", applyCommentModal); - commentApplyInlineButton?.addEventListener("click", applyCommentModal); + commentModalExpandButton?.addEventListener("click", () => setCommentModalExpanded(!commentModalExpanded)); + commentApplyButton?.addEventListener("click", () => applyCommentModal({ closeOnSave: false })); + commentSaveButton?.addEventListener("click", () => applyCommentModal({ closeOnSave: true })); + commentApplyInlineButton?.addEventListener("click", () => applyCommentModal({ closeOnSave: false })); commentModalBackdrop?.addEventListener("click", (event) => { if (event.target === commentModalBackdrop) closeCommentModal(); }); commentAttachButton?.addEventListener("click", () => commentAttachmentInput?.click()); commentReplyAttachButton?.addEventListener("click", () => commentReplyAttachmentInput?.click()); - commentAddSubitemButton?.addEventListener("click", () => { - commentDraftSubitems.push({ - id: `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`, - title: "", - done: false, - }); - renderCommentSubitems(); - const inputs = commentSubitemsList?.querySelectorAll?.(".ops-comment-subitem input[type='text']"); - inputs?.[inputs.length - 1]?.focus?.(); + commentAddSubitemButton?.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + toggleCommentSubitemMenu(); + }); + commentSubitemMenu?.addEventListener("click", (event) => { + const button = event.target?.closest?.("[data-comment-subitem-kind]"); + if (!button) return; + event.preventDefault(); + event.stopPropagation(); + addCommentSubitem(button.dataset.commentSubitemKind); + hideCommentSubitemMenu(); + }); + document.addEventListener("click", (event) => { + if (!commentSubitemMenu || commentSubitemMenu.classList.contains("hidden")) return; + if (commentSubitemMenu.contains(event.target) || commentAddSubitemButton?.contains(event.target)) return; + hideCommentSubitemMenu(); }); commentAttachmentInput?.addEventListener("change", async (event) => { const files = await readAttachmentFiles(event.target.files); @@ -5306,6 +5439,12 @@ const loadProjectSnapshot = async (project) => { document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; + if (commentSubitemMenu && !commentSubitemMenu.classList.contains("hidden")) { + hideCommentSubitemMenu(); + e.preventDefault(); + e.stopPropagation(); + return; + } if (commentModal && !commentModal.classList.contains("hidden")) { closeCommentModal(); e.preventDefault(); diff --git a/frontend/index.html b/frontend/index.html index 824e848..53796d6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -134,7 +134,7 @@ - +