Add BIM model comments
This commit is contained in:
parent
c248217976
commit
684f85b382
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
290
server/index.js
290
server/index.js
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue