diff --git a/index.html b/index.html
index b9c3d02..ea2aeb8 100644
--- a/index.html
+++ b/index.html
@@ -38,6 +38,10 @@
rel="stylesheet"
href="/src/MeasurementControl/measurementsPanel.css"
/>
+
diff --git a/src/AnnotationControl/annotationPanel.css b/src/AnnotationControl/annotationPanel.css
new file mode 100644
index 0000000..f8bebc8
--- /dev/null
+++ b/src/AnnotationControl/annotationPanel.css
@@ -0,0 +1,279 @@
+/* Hide Potree annotation icon specifically */
+/* This targets the
that references annotation.svg. */
+img.button-icon[src$="/annotation.svg"]{
+ display: none !important;
+ visibility: hidden !important;
+ width: 0 !important;
+ height: 0 !important;
+ margin: 0 !important;
+ padding: 0 !important;
+}
+
+.annotation-desc {
+ font-size: 0.85em;
+ margin-left: 8px;
+}
+
+.annotation-info {
+ font-size: 0.8em;
+ margin-left: 8px;
+}
+
+.annotation-edit-textarea {
+ width: 100%;
+}
+
+.annotation-add-button {
+ margin: 10px 0;
+}
+
+.annotation-empty {
+ opacity: 0.6;
+ padding: 10px;
+ text-align: center;
+}
+
+.annotation-row {
+ display: flex;
+ flex-direction: column; /* stack header above body */
+ gap: 6px;
+ padding: 6px 8px;
+ margin: 10px 2px;
+ border-radius: 6px;
+ background: #2c3539;
+ border: 1px solid transparent;
+ transition: background 0.15s, border-color 0.15s;
+ color: #d9e2e6;
+}
+
+.annotation-row .annotation-label {
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.annotation-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+}
+.annotation-body {
+ width: 100%;
+ margin-top: 6px;
+}
+
+/* By default hide the body (details); show when row has open */
+.annotation-body {
+ display: none;
+}
+.annotation-row.open .annotation-body {
+ display: block;
+}
+
+.annotation-row .toggle-triangle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ font-size: 12px;
+ color: #8fb9c9;
+ vertical-align: middle;
+ cursor: pointer;
+}
+.annotation-row .toggle-triangle::after {
+ content: '▸';
+}
+.annotation-row.open .toggle-triangle::after {
+ content: '▾';
+}
+
+/* Jump button */
+.annotation-row .jump-btn {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: transparent;
+ color: #7fbcd3;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: 8px;
+ border: 1px solid rgba(127,188,211,0.28);
+ cursor: pointer;
+ transition: transform 0.12s ease, background 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease, color 0.12s ease;
+ box-shadow: none;
+}
+.annotation-row .jump-btn::after {
+ content: '→';
+ font-weight: 700;
+ font-size: 13px;
+ line-height: 1;
+}
+.annotation-row .jump-btn:hover {
+ color: #7fbcd3;
+ border-color: rgba(14,166,217,0.44);
+ box-shadow: 0 8px 18px rgba(14,166,217,0.14), 0 0 0 3px rgba(14,166,217,0.06);
+ background: transparent;
+}
+.annotation-row .jump-btn:focus {
+ outline: none;
+ border-color: rgba(14,166,217,0.44);
+ box-shadow: 0 8px 22px rgba(14,166,217,0.18), 0 0 0 4px rgba(14,166,217,0.06);
+}
+.annotation-row .jump-btn:active,
+.annotation-row .jump-btn[aria-pressed="true"] {
+ transform: translateY(1px);
+ background: linear-gradient(180deg, #28c1ff 0%, #0ea6d9 100%);
+ color: #fff;
+ border-color: transparent;
+ box-shadow: 0 6px 20px rgba(14,166,217,0.26), inset 0 1px 0 rgba(255,255,255,0.14);
+}
+
+.annotation-row .jump-btn.recently-pressed {
+ transform: translateY(1px);
+ background: linear-gradient(180deg, #28c1ff 0%, #0ea6d9 100%);
+ color: #fff;
+ border-color: transparent;
+ box-shadow: 0 6px 20px rgba(14,166,217,0.26), inset 0 1px 0 rgba(255,255,255,0.14);
+ transition: background 1.4s cubic-bezier(.2,.9,.2,1), box-shadow 1.4s cubic-bezier(.2,.9,.2,1), transform 0.12s ease, color 1.0s ease, opacity 1.4s ease;
+}
+
+.annotation-row .jump-btn.jump-disabled,
+.annotation-row .jump-btn:disabled {
+ opacity: 0.44;
+ color: #9fbfcf;
+ border-color: rgba(127,188,211,0.12);
+ box-shadow: none;
+ cursor: default;
+ pointer-events: none;
+}
+
+.annotation-row .jump-btn.jump-disabled:hover,
+.annotation-row .jump-btn.jump-disabled:focus,
+.annotation-row .jump-btn:disabled:hover,
+.annotation-row .jump-btn:disabled:focus {
+ transform: none;
+ box-shadow: none;
+ background: transparent;
+}
+
+
+.annotation-row .del-btn {
+ background: #3b2626;
+ border: 1px solid #5a3a3a;
+ color: #ff9a9a;
+ font-weight: 600;
+ font-size: 11px;
+ line-height: 1;
+ padding: 4px 6px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ color 0.15s;
+ margin-left: 8px;
+}
+.annotation-row .del-btn:hover {
+ background: #5a2d2d;
+ color: #fff;
+}
+
+.annotation-row:hover {
+ background: #354045;
+ border-color: #425056;
+}
+.annotation-row.active {
+ background: #1f4b63;
+ border-color: #2f6b8c;
+ box-shadow: 0 0 0 1px #2f6b8c66;
+}
+
+.annotation-row .annotation-desc,
+.annotation-row .annotation-info {
+ background: #2f383d;
+ padding: 8px;
+ border: 1px solid #404a50;
+ border-radius: 4px;
+ color: #cfd5d8;
+ font-family: inherit;
+ font-size: 12px;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.annotation-row .annotation-desc,
+.annotation-row .annotation-info {
+ display: none;
+}
+.annotation-row.open .annotation-desc,
+.annotation-row.open .annotation-info {
+ display: block;
+ margin-top: 6px;
+}
+
+.annotation-header .annotation-label {
+ margin-right: 8px;
+}
+.annotation-header .controls {
+ margin-left: auto;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex: 0 0 auto;
+}
+
+.annotation-header input[type="text"],
+.annotation-header .annotation-label input[type="text"] {
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Make the caret/triangle take up fixed space so it doesn't shift */
+.annotation-row .toggle-triangle {
+ flex: 0 0 18px;
+}
+
+/* Add button */
+.annotation-add-button {
+ background: linear-gradient(180deg,#f6f6f6 0%, #e9e9e9 100%);
+ color: #222;
+ padding: 8px 16px;
+ min-width: 140px;
+ height: 38px;
+ display: block;
+ margin: 12px auto;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 700;
+ box-shadow: 0 1px 0 rgba(255,255,255,0.6) inset;
+ border: 1px solid #cfcfcf;
+ cursor: pointer;
+ text-align: center;
+}
+.annotation-add-button .add-label {
+ color: #222;
+ font-weight: 700;
+}
+.annotation-add-button:hover {
+ background: linear-gradient(180deg,#f3f3f3 0%, #e2e2e2 100%);
+ border-color: #bfbfbf;
+}
+.annotation-add-button:active {
+ transform: translateY(1px);
+ background: linear-gradient(180deg,#e9e9e9 0%, #dbdbdb 100%);
+ box-shadow: inset 0 2px 6px rgba(0,0,0,0.06);
+}
+.annotation-add-button:focus {
+ outline: 2px solid rgba(100,100,100,0.12);
+ outline-offset: 2px;
+}
+
diff --git a/src/AnnotationControl/annotationPanel.js b/src/AnnotationControl/annotationPanel.js
new file mode 100644
index 0000000..5c4a9f9
--- /dev/null
+++ b/src/AnnotationControl/annotationPanel.js
@@ -0,0 +1,741 @@
+import {initAnnotationPersistence} from './persistence'
+
+/**
+ * Annotations Panel
+ * Injects a custom Annotations section for storing camera positions,
+ * that will (later) be used for letting users rapidly jump to saved views.
+ */
+export function initAnnotationsPanel(viewer) {
+ // Container management
+ const existingListContainer = document.getElementById('annotations_list')
+ let targetContainer = existingListContainer
+ if (!targetContainer) {
+ const menu = document.getElementById('potree_menu')
+ if (menu) {
+ const header = document.createElement('h3')
+ header.id = 'menu_camera_annotations'
+ const headerSpan = document.createElement('span')
+ headerSpan.textContent = 'Saved Locations'
+ header.appendChild(headerSpan)
+
+ const panel = document.createElement('div')
+ panel.className = 'pv-menu-list annotations-panel'
+
+ const listContainerDiv = document.createElement('div')
+ listContainerDiv.id = 'annotations_list'
+ listContainerDiv.className = 'auto'
+ panel.appendChild(listContainerDiv)
+
+ // Insert after measurement panel but before tools, or at end if not found
+ const measurements = document.querySelector('.measurements-panel')
+ if (measurements) {
+ menu.insertBefore(panel, measurements.nextSibling)
+ menu.insertBefore(header, panel)
+ } else {
+ menu.appendChild(header)
+ menu.appendChild(panel)
+ }
+
+ if ($(menu).accordion) {
+ try {
+ $(menu).accordion('refresh')
+ } catch (e) {}
+ }
+
+ // Toggle collapse
+ header.addEventListener('click', () => {
+ if ($(menu).accordion && $(menu).data('uiAccordion')) return
+ if (window.jQuery) {
+ const $p = window.jQuery(panel)
+ $p.is(':visible') ? $p.slideUp(350) : $p.slideDown(350)
+ return
+ }
+ })
+ targetContainer = panel.querySelector('#annotations_list')
+ }
+ }
+ if (!targetContainer) {
+ console.warn(
+ 'Annotations list container not found and dynamic injection failed'
+ )
+ return
+ }
+
+ // UI update
+ // Helper: normalize different vector shapes into [x,y,z] (necessary for handling both Three.js Vector3 and serialized data stored in the jsTree)
+ function vecToArray(v) {
+ if (!v) return null
+ if (Array.isArray(v)) return v
+ if (typeof v.toArray === 'function') return v.toArray()
+ if (v.x != null && v.y != null && v.z != null) return [v.x, v.y, v.z]
+ return null
+ }
+ // Monotonic numbering: assign an increasing number to each UUID and never reuse numbers.
+ const _uuidToIndex = new Map()
+ let _nextIndex = 1
+
+ function _ensureIndexForUUID(uuid) {
+ if (!uuid) return null
+ if (!_uuidToIndex.has(uuid)) {
+ _uuidToIndex.set(uuid, _nextIndex++)
+ }
+ return _uuidToIndex.get(uuid)
+ }
+
+ // Helper: safe accessors and utilities for jsTree and annotation descriptions
+ function _getJSTree() {
+ try {
+ return $('#jstree_scene').jstree && $('#jstree_scene').jstree()
+ } catch (e) {
+ return null
+ }
+ }
+
+ function _getJSTreeInstance() {
+ try {
+ return ($('#jstree_scene').jstree && $('#jstree_scene').jstree(true)) ||
+ (typeof $.jstree !== 'undefined' && $.jstree.reference && $.jstree.reference('#jstree_scene')) ||
+ null
+ } catch (e) {
+ return null
+ }
+ }
+
+ function _getAnnotationsRoot() {
+ const t = _getJSTree()
+ try {
+ return t ? t.get_json('annotations') : null
+ } catch (e) {
+ return null
+ }
+ }
+
+ function _findNodeInAnnotationsRootByUUID(root, uuid) {
+ if (!root || !root.children || !uuid) return null
+ return root.children.find((c) => (c.data && c.data.uuid) === uuid) || null
+ }
+
+ function _getDescriptionForUUID(uuid, nodeData) {
+ // Prefer live annotation description, then node data (desc/description)
+ if (!uuid && !nodeData) return ''
+ try {
+ const live = _findLiveAnnotationByUUID(uuid)
+ if (live) {
+ if (live.data && typeof live.data.description !== 'undefined') return String(live.data.description || '')
+ if (typeof live.description !== 'undefined') return String(live.description || '')
+ }
+ } catch (e) {}
+ const ann = nodeData || {}
+ return (ann.description && String(ann.description)) || (ann.desc && String(ann.desc)) || ''
+ }
+
+ function _renameJSTreeNode(nodeId, text) {
+ try {
+ if (window.$ && $.jstree) {
+ if ($.jstree.reference) {
+ const ref = $.jstree.reference(nodeId)
+ if (ref && typeof ref.rename_node === 'function') {
+ ref.rename_node(nodeId, text)
+ return
+ }
+ }
+ // fallback to global selector
+ if ($('#jstree_scene') && $('#jstree_scene').jstree) {
+ try {
+ $('#jstree_scene').jstree('rename_node', nodeId, text)
+ return
+ } catch (e) {}
+ }
+ }
+ } catch (e) {
+ // ignore rename failures
+ }
+ }
+
+ function _findLiveAnnotationByUUID(uuid) {
+ if (!uuid || !viewer || !viewer.scene || !viewer.scene.annotations) return null
+ try {
+ const coll =
+ (viewer.scene.annotations.flatten && viewer.scene.annotations.flatten()) ||
+ (viewer.scene.annotations.descendants && viewer.scene.annotations.descendants()) ||
+ viewer.scene.annotations.children || []
+ for (const a of coll) {
+ if (!a) continue
+ if (a.uuid === uuid || (a.data && a.data.uuid === uuid)) return a
+ }
+ } catch (e) {
+ // fallback
+ try {
+ const coll2 = viewer.scene.annotations.children || []
+ for (const a of coll2) if (a && (a.uuid === uuid || (a.data && a.data.uuid === uuid))) return a
+ } catch (e) {}
+ }
+ return null
+ }
+ function updateAnnotationsList() {
+ // Implementation for listing annotations
+ targetContainer.innerHTML = ''
+ const annotationsTree = _getJSTree()
+ if (!annotationsTree) return
+ let annotationsRoot = _getAnnotationsRoot()
+ if (
+ !annotationsRoot ||
+ !annotationsRoot.children ||
+ annotationsRoot.children.length === 0
+ ) {
+ const empty = document.createElement('div')
+ empty.className = 'annotation-empty'
+ empty.textContent = 'No saved positions yet'
+ targetContainer.appendChild(empty)
+ return
+ }
+ // Assign monotonic indices for any node UUIDs encountered and rename unlabeled nodes
+ try {
+ const items = (annotationsRoot.children || []).map((n) => ({ data: n.data || {}, node: n }))
+ for (const it of items) {
+ const uuid = (it.data && it.data.uuid) || null
+ if (!uuid) continue
+ const idx = _ensureIndexForUUID(uuid)
+ const text = (it.node && it.node.text) || ''
+ const shouldRename = !text || text.trim() === '' || text === 'Unnamed' || text === 'Annotation Title'
+ if (shouldRename && idx != null) {
+ const newName = `Annotation #${idx}`
+ _renameJSTreeNode(it.node.id, newName)
+ it.node.text = newName
+ // update live annotation object's title/name so in-scene label matches
+ try {
+ const live = _findLiveAnnotationByUUID(it.data.uuid)
+ if (live) {
+ if (typeof live.title !== 'undefined') live.title = newName
+ if (typeof live.name !== 'undefined') live.name = newName
+ if (live.data) live.data.title = newName
+ }
+ } catch (e) {}
+ }
+ }
+ } catch (e) {
+ // ignore failures
+ }
+
+ // Build list entries for each annotation node
+ annotationsRoot.children.forEach((node) => {
+
+ const row = document.createElement('div')
+ row.className = 'annotation-row'
+
+ // header and body structure
+ const header = document.createElement('div')
+ header.className = 'annotation-header'
+ const body = document.createElement('div')
+ body.className = 'annotation-body'
+
+ const label = document.createElement('span')
+ label.textContent = node.text || 'Unnamed'
+ label.className = 'annotation-label'
+ // attach uuid for editing
+ try {
+ const uuid = (node.data && node.data.uuid) || null
+ if (uuid) label.dataset.uuid = uuid
+ } catch (e) {}
+ // double-click label to edit
+ label.addEventListener('dblclick', (ev) => {
+ ev.stopPropagation()
+ const uuid = label.dataset.uuid
+ if (uuid) startInlineEditForUUID(uuid)
+ })
+
+ // triangular toggle (collapsed/open)
+ const toggle = document.createElement('span')
+ toggle.className = 'toggle-triangle'
+ toggle.title = 'Toggle details'
+
+ // Jump button
+
+ const jumpBtn = document.createElement('button')
+ jumpBtn.className = 'jump-btn'
+ jumpBtn.title = 'Move to this position'
+ jumpBtn.setAttribute('aria-label', 'Jump to saved view')
+ // Move the viewer to the saved camera position/pivot for this annotation (if present)
+ jumpBtn.onclick = () => {
+ const ann = node.data || {}
+ const camPos = vecToArray(
+ ann.cameraPosition || ann.camera_position || ann.cameraPos
+ )
+ const annPos = vecToArray(
+ ann.position || ann.annotationPosition || ann.pos
+ )
+
+ if (
+ camPos &&
+ viewer &&
+ viewer.scene &&
+ viewer.scene.view &&
+ typeof viewer.scene.view.setView === 'function'
+ ) {
+ const target = annPos || null
+ viewer.scene.view.setView(camPos, target, 1000) // animation duration in ms
+
+ // Transient visual feedback: mark button as recently pressed so CSS can
+ // show the filled gradient and glow, then fade it back automatically.
+ try {
+ jumpBtn.setAttribute('aria-pressed', 'true')
+ jumpBtn.classList.add('recently-pressed')
+ // remove after short delay to allow CSS fade-out
+ window.setTimeout(() => {
+ try {
+ jumpBtn.classList.remove('recently-pressed')
+ jumpBtn.setAttribute('aria-pressed', 'false')
+ } catch (e) {}
+ }, 200)
+ } catch (e) {}
+ }
+ }
+
+ // Delete button
+ const delBtn = document.createElement('button')
+ delBtn.className = 'del-btn'
+ delBtn.textContent = '✖'
+ delBtn.title = 'Delete saved position'
+ // Delete an annotation: remove annotation from the renderer scene (live annotation), then remove the tree node
+ delBtn.onclick = () => {
+ const annData = node.data || {}
+ const uuid = annData.uuid
+
+ if (uuid && viewer && viewer.scene && viewer.scene.annotations) {
+ // Find the live annotation instance by UUID (Universally Unique Identifier)
+ let candidates = []
+ try {
+ candidates =
+ (viewer.scene.annotations.flatten &&
+ viewer.scene.annotations.flatten()) ||
+ (viewer.scene.annotations.descendants &&
+ viewer.scene.annotations.descendants()) ||
+ viewer.scene.annotations.children ||
+ []
+ } catch (e) {
+ candidates = viewer.scene.annotations.children || []
+ }
+
+ let live = null
+ if (Array.isArray(candidates)) {
+ for (let a of candidates) {
+ if (!a) continue
+ if (a.uuid === uuid || (a.data && a.data.uuid === uuid)) {
+ live = a
+ break
+ }
+ }
+ }
+
+ if (live) {
+ if (typeof live.removeHandles === 'function')
+ live.removeHandles(viewer)
+ if (typeof viewer.scene.removeAnnotation === 'function') {
+ viewer.scene.removeAnnotation(live)
+ } else if (
+ viewer.scene.annotations &&
+ typeof viewer.scene.annotations.remove === 'function'
+ ) {
+ viewer.scene.annotations.remove(live)
+ }
+ }
+ }
+
+ // remove from sidebar/tree
+ try {
+ $('#jstree_scene').jstree('delete_node', node.id)
+ } catch (e) {
+ try {
+ $.jstree.reference(node.id).delete_node(node.id)
+ } catch (_) {}
+ }
+ updateAnnotationsList()
+ }
+
+ // Append elements into header: toggle, label, then controls (jump/delete)
+ const controls = document.createElement('div')
+ controls.className = 'controls'
+ controls.appendChild(jumpBtn)
+ controls.appendChild(delBtn)
+
+ header.appendChild(toggle)
+ header.appendChild(label)
+ header.appendChild(controls)
+ row.appendChild(header)
+
+ // Wire toggle to show/hide details
+ try {
+ toggle.addEventListener('click', (ev) => {
+ ev.stopPropagation()
+ row.classList.toggle('open')
+ })
+ } catch (e) {}
+ // Description view,supports dblclick edit
+ try {
+ const ann = node.data || {}
+ const descText = _getDescriptionForUUID(ann.uuid, ann)
+ const display = descText.trim() ? descText : 'Annotation Description'
+ const desc = document.createElement('div')
+ desc.className = 'annotation-desc'
+ desc.textContent = display
+ desc.dataset.uuid = (ann && ann.uuid) || ''
+ desc.dataset.raw = descText.trim()
+ desc.addEventListener('dblclick', (ev) => {
+ ev.stopPropagation()
+ const u = desc.dataset.uuid
+ if (u) startInlineDescriptionEditForUUID(u)
+ })
+ body.appendChild(desc)
+ } catch (e) {
+ // ignore description rendering errors
+ }
+
+ // show saved camera and point info
+ try {
+ const ann = node.data || {}
+
+ const cam = vecToArray(
+ ann.cameraPosition || ann.camera_position || ann.cameraPos
+ )
+
+ // Start with serialized position, then prefer the live object's current position
+ let pointPos = vecToArray(
+ ann.position || ann.annotationPosition || ann.pos
+ )
+ try {
+ const live = _findLiveAnnotationByUUID(ann.uuid)
+ if (live) {
+ const livePos = vecToArray(live.position || (live.data && live.data.position))
+ if (livePos) pointPos = livePos
+ }
+ } catch (e) {}
+
+ // Hide Potree's default placeholder coordinates until the annotation is actually placed
+ function approxEqual(a, b, eps = 1e-3) {
+ if (!a || !b || a.length !== b.length) return false
+ for (let i = 0; i < a.length; i++) if (Math.abs(Number(a[i]) - Number(b[i])) > eps) return false
+ return true
+ }
+ const PLACEHOLDER_POS = [589748.27, 231444.54, 753.675]
+ if (pointPos && approxEqual(pointPos, PLACEHOLDER_POS)) pointPos = null
+
+ if (cam || pointPos) {
+ const info = document.createElement('div')
+ info.className = 'annotation-info'
+ const fmt = (v) => (v ? v.map((c) => Number(c).toFixed(3)).join(', ') : '—')
+ info.innerHTML = `Camera coordinates: ${fmt(cam)}
Saved Point coordinates: ${fmt(pointPos)}`
+ body.appendChild(info)
+
+ // Enable or disable jump button depending on whether we have a saved point position
+ try {
+ if (pointPos && Array.isArray(pointPos) && pointPos.length === 3) {
+ jumpBtn.disabled = false
+ jumpBtn.classList.remove('jump-disabled')
+ } else {
+ jumpBtn.disabled = true
+ jumpBtn.classList.add('jump-disabled')
+ }
+ } catch (e) {}
+ }
+ } catch (e) {
+ // ignore formatting errors
+ }
+ // Append body last so it's hidden until opened
+ row.appendChild(body)
+ targetContainer.appendChild(row)
+ })
+ }
+
+ // Start inline editing for a given annotation UUID (opens an input in the sidebar)
+ function startInlineEditForUUID(uuid) {
+ if (!uuid) return
+ const labelEl = targetContainer.querySelector(`.annotation-label[data-uuid="${uuid}"]`)
+ if (!labelEl) return
+ const oldText = labelEl.textContent || ''
+ const input = document.createElement('input')
+ input.type = 'text'
+ input.value = oldText
+ input.className = 'annotation-edit-input'
+ labelEl.replaceWith(input)
+ input.focus()
+ input.select()
+
+ function finish(commit) {
+ const newText = commit ? input.value.trim() || oldText : oldText
+ // restore label
+ const newLabel = document.createElement('span')
+ newLabel.className = 'annotation-label'
+ newLabel.textContent = newText
+ newLabel.dataset.uuid = uuid
+ newLabel.addEventListener('dblclick', (ev) => {
+ ev.stopPropagation()
+ startInlineEditForUUID(uuid)
+ })
+
+ try {
+ if (input.isConnected) {
+ input.replaceWith(newLabel)
+ } else {
+ const existing = targetContainer.querySelector(`.annotation-label[data-uuid="${uuid}"]`)
+ if (existing) existing.textContent = newText
+ }
+ } catch (e) {
+ // ignore DOM replacement errors and try to update label if present
+ try {
+ const existing = targetContainer.querySelector(`.annotation-label[data-uuid="${uuid}"]`)
+ if (existing) existing.textContent = newText
+ } catch (_) {}
+ }
+ // commit to jsTree and live annotation
+ _commitEditedName(uuid, newText)
+ }
+
+ input.addEventListener('blur', () => finish(true))
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ finish(true)
+ } else if (e.key === 'Escape') {
+ finish(false)
+ }
+ })
+ }
+
+ // Start inline editing for an annotation description (multiline)
+ function startInlineDescriptionEditForUUID(uuid) {
+ if (!uuid) return
+ const descEl = targetContainer.querySelector(`.annotation-desc[data-uuid="${uuid}"]`)
+ if (!descEl) return
+ let oldText = descEl.dataset.raw || descEl.textContent || ''
+ try {
+ const live = _findLiveAnnotationByUUID(uuid)
+ if (live) {
+ if (live.data && typeof live.data.description !== 'undefined') {
+ oldText = String(live.data.description || '')
+ } else if (typeof live.description !== 'undefined') {
+ oldText = String(live.description || '')
+ }
+ }
+ } catch (e) {}
+
+ const ta = document.createElement('textarea')
+ ta.className = 'annotation-edit-textarea'
+ // Prefill textarea with existing description
+ ta.value = oldText
+ ta.rows = 1
+ descEl.replaceWith(ta)
+ ta.focus()
+ try {
+ ta.select()
+ } catch (e) {}
+
+ function finish(commit) {
+ const newText = commit ? (ta.value.trim() || '') : oldText
+ const displayText = newText ? newText : 'Annotation Description'
+ const newDesc = document.createElement('div')
+ newDesc.className = 'annotation-desc'
+ newDesc.textContent = displayText
+ newDesc.dataset.uuid = uuid
+ newDesc.dataset.raw = newText
+ newDesc.addEventListener('dblclick', (ev) => {
+ ev.stopPropagation()
+ startInlineDescriptionEditForUUID(uuid)
+ })
+
+ try {
+ if (ta.isConnected) {
+ ta.replaceWith(newDesc)
+ } else {
+ const existing = targetContainer.querySelector(`.annotation-desc[data-uuid="${uuid}"]`)
+ if (existing) existing.textContent = displayText
+ }
+ } catch (e) {
+ try {
+ const existing = targetContainer.querySelector(`.annotation-desc[data-uuid="${uuid}"]`)
+ if (existing) existing.textContent = displayText
+ } catch (_) {}
+ }
+
+ _commitEditedDescription(uuid, newText)
+ }
+
+ ta.addEventListener('blur', () => finish(true))
+ ta.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ // Shift+Enter: insert newline (allow default). Enter: finish and save.
+ if (e.shiftKey) {
+ // allow default -> newline inserted
+ return
+ }
+ e.preventDefault()
+ finish(true)
+ } else if (e.key === 'Escape') {
+ finish(false)
+ }
+ })
+ }
+
+ function _commitEditedDescription(uuid, description) {
+ if (!uuid) return
+ try {
+ const tree = _getJSTree()
+ if (tree) {
+ const annotationsRoot = _getAnnotationsRoot()
+ const node = _findNodeInAnnotationsRootByUUID(annotationsRoot, uuid)
+ if (node) {
+ node.data = node.data || {}
+ node.data.description = description
+
+ try {
+ const jsTreeInst = _getJSTreeInstance()
+ if (jsTreeInst) {
+ const jsNode = jsTreeInst.get_node(node.id)
+ if (jsNode) {
+ jsNode.original = jsNode.original || {}
+ jsNode.original.data = jsNode.original.data || {}
+ jsNode.original.data.description = description
+ }
+ }
+ } catch (e) {
+ // If we couldn't access the internal node, continue anyway
+ }
+ }
+ }
+ } catch (e) {}
+
+ try {
+ const live = _findLiveAnnotationByUUID(uuid)
+ if (live) {
+ live.data = live.data || {}
+ live.data.description = description
+ if (typeof live.description !== 'undefined') live.description = description
+ if (typeof live.desc !== 'undefined') live.desc = description
+ }
+ } catch (e) {}
+
+ setTimeout(updateAnnotationsList, 0)
+ }
+
+ function _commitEditedName(uuid, name) {
+ if (!uuid) return
+ // update jsTree node text
+ try {
+ const tree = $('#jstree_scene').jstree && $('#jstree_scene').jstree()
+ if (tree) {
+ const annotationsRoot = tree.get_json('annotations')
+ if (annotationsRoot && annotationsRoot.children) {
+ const node = annotationsRoot.children.find((c) => (c.data && c.data.uuid) === uuid)
+ if (node) {
+ _renameJSTreeNode(node.id, name)
+ node.text = name
+ }
+ }
+ }
+ } catch (e) {}
+
+ // update live annotation
+ try {
+ const live = _findLiveAnnotationByUUID(uuid)
+ if (live) {
+ if (typeof live.title !== 'undefined') live.title = name
+ if (typeof live.name !== 'undefined') live.name = name
+ if (live.data) live.data.title = name
+ }
+ } catch (e) {}
+
+ // refresh sidebar to reflect changes
+ setTimeout(updateAnnotationsList, 0)
+ }
+
+ // Add Annotation UI (mimics Potree toolbar/pinpoint-button logic)
+ function createAddButton() {
+ const btn = document.createElement('button')
+ btn.className = 'annotation-add-button'
+ btn.setAttribute('aria-label', 'Add a new saved location')
+ btn.innerHTML = `Add a location`
+ btn.onclick = () => {
+ // Show this panel, if collapsed
+ const menu = document.getElementById('potree_menu')
+ const annotationHeader = document.getElementById(
+ 'menu_camera_annotations'
+ )
+ if (annotationHeader && annotationHeader.nextElementSibling) {
+ $(annotationHeader.nextElementSibling).slideDown()
+ }
+ // Capture current camera view (position) at the moment the user clicks Add
+ let camPos = null
+ try {
+ if (viewer && viewer.scene && viewer.scene.view) {
+ camPos =
+ viewer.scene.view.position &&
+ typeof viewer.scene.view.position.toArray === 'function'
+ ? viewer.scene.view.position.toArray()
+ : viewer.scene.view.position
+ ? [
+ viewer.scene.view.position.x,
+ viewer.scene.view.position.y,
+ viewer.scene.view.position.z
+ ]
+ : null
+ }
+ } catch (e) {
+ console.warn('Could not read current view for annotation', e)
+ }
+
+ // Start Potree annotation insertion
+ let annotation = viewer.annotationTool.startInsertion()
+
+ // Wait for the actual placement (left click) before updating the sidebar.
+ try {
+ const dom = viewer && viewer.renderer && viewer.renderer.domElement
+ if (dom) {
+ const onMouseUp = (ev) => {
+ try {
+ if (ev.button === 0) {
+ // left click = placement finished
+ setTimeout(() => updateAnnotationsList(), 50)
+ dom.removeEventListener('mouseup', onMouseUp, true)
+ }
+ } catch (e) {}
+ }
+ dom.addEventListener('mouseup', onMouseUp, true)
+ }
+ } catch (e) {
+ // ignore
+ }
+
+ // Wait for annotation creation, then select in jsTree
+ setTimeout(() => {
+ let tree = $('#jstree_scene').jstree && $('#jstree_scene').jstree()
+ if (!tree || !annotation) return
+ // Persist captured camera info on the annotation object so it will be available in the jsTree node data
+ try {
+ if (camPos) annotation.cameraPosition = camPos
+ } catch (e) {
+ console.warn('Could not attach camera info to annotation', e)
+ }
+ updateAnnotationsList()
+ }, 200) // A short delay for Potree/jsTree to update
+ }
+ targetContainer.parentElement.insertBefore(btn, targetContainer)
+ }
+
+ createAddButton()
+ updateAnnotationsList()
+ initAnnotationPersistence(viewer)
+
+ // Listen to Potree's annotation events to auto-refresh the list
+ if (viewer.scene && viewer.scene.annotations) {
+ viewer.scene.annotations.addEventListener(
+ 'annotation_added',
+ updateAnnotationsList
+ )
+ viewer.scene.annotations.addEventListener(
+ 'annotation_removed',
+ updateAnnotationsList
+ )
+ viewer.scene.annotations.addEventListener(
+ 'annotation_changed',
+ updateAnnotationsList
+ )
+ }
+}
diff --git a/src/potreeViewer.js b/src/potreeViewer.js
index 9ccd25e..24fb4c9 100644
--- a/src/potreeViewer.js
+++ b/src/potreeViewer.js
@@ -1,6 +1,6 @@
+import { initAnnotationsPanel } from './AnnotationControl/AnnotationPanel.js'
import { initElevationControls } from './ElevationControl/elevationControl.js'
import { initMeasurementsPanel } from './MeasurementControl/measurementsPanel.js'
-import { initAnnotationPersistence } from './Annotations/persistence.js'
import { ecef } from './config.js'
/**
@@ -38,11 +38,7 @@ export async function createPotreeViewer(containerId, pointcloudUrl, settings) {
initElevationControls(viewer)
initMeasurementsPanel(viewer)
- initAnnotationPersistence(viewer, {
- jsonUrl: '/annotations/annotations.json',
- autosave: true,
- pointcloudUrl
- })
+ initAnnotationsPanel(viewer)
})
const e = await Potree.loadPointCloud(pointcloudUrl)