diff --git a/cypress/e2e/notes-100/share-a-note.cy.js b/cypress/e2e/notes-100/share-a-note.cy.js
index 4fbaf9138..d69fa2f9c 100644
--- a/cypress/e2e/notes-100/share-a-note.cy.js
+++ b/cypress/e2e/notes-100/share-a-note.cy.js
@@ -13,13 +13,13 @@ describe('Notes-100: Share a note', () => {
context('Open Notes > first note, click share in note footer', () => {
beforeEach(() => {
cy.get('[data-testid="control-button-notes"]').click()
- cy.get('[data-testid="list-notes"] :nth-child(1) > [data-testid="note-body"]').first().click()
+ cy.get('[data-testid="list-notes"] [data-testid="note-body"]').first().click()
cy.window().then((win) => {
cy.stub(win.navigator.clipboard, 'writeText').as('clipboardSpy')
.resolves()
})
- cy.get('.MuiCardActions-root > [data-testid="Share"] > .icon-share').click()
+ cy.get('.MuiCardActions-root > [data-testid="Share"] > .icon-share').first().click()
})
it('Link copied, SnackBar reports that - Screen', () => {
diff --git a/cypress/e2e/placemarks-100/marker-selection.cy.js b/cypress/e2e/placemarks-100/marker-selection.cy.js
new file mode 100644
index 000000000..06a068bff
--- /dev/null
+++ b/cypress/e2e/placemarks-100/marker-selection.cy.js
@@ -0,0 +1,77 @@
+import '@percy/cypress'
+import {Raycaster, Vector2, Vector3} from 'three'
+import {homepageSetup, returningUserVisitsHomepageWaitForModel} from '../../support/utils'
+import {MOCK_MARKERS} from '../../../src/Components/Markers/Marker.fixture'
+
+
+/** {@link https://github.com/bldrs-ai/Share/issues/1054} */
+describe('Placemarks 100: Not visible when notes is not open', () => {
+ beforeEach(homepageSetup)
+ context('Returning user visits homepage', () => {
+ beforeEach(returningUserVisitsHomepageWaitForModel)
+
+ context('Select a marker', () => {
+ let win
+ beforeEach(() => {
+ cy.get('[data-testid="control-button-notes"]').click()
+ cy.get('[data-testid="list-notes"]')
+ cy.get('[data-testid="panelTitle"]').contains('NOTES')
+
+ cy.window().then((window) => {
+ win = window
+ })
+ // eslint-disable-next-line cypress/no-unnecessary-waiting, no-magic-numbers
+ cy.wait(1000)
+ })
+ it('should select a marker and url hash should change', () => {
+ const {markerObjects, camera, domElement} = win.markerScene
+
+ // Assert that markers exist
+ expect(markerObjects.length).to.eq(2)
+
+ // Get the first marker's position
+ const markerCoordinates = MOCK_MARKERS[0].coordinates
+ const markerPosition = new Vector3(markerCoordinates[0], markerCoordinates[1], markerCoordinates[2])
+
+ // Project marker position to NDC
+ const ndc = markerPosition.project(camera)
+
+ // Calculate the screen position of the marker
+ const canvasRect = domElement.getBoundingClientRect()
+ // eslint-disable-next-line no-mixed-operators
+ const screenX = ((ndc.x + 1) / 2) * canvasRect.width + canvasRect.left
+ // eslint-disable-next-line no-mixed-operators
+ const screenY = ((1 - ndc.y) / 2) * canvasRect.height + canvasRect.top
+
+
+ // Perform raycasting after updating the pointer
+ const raycaster = new Raycaster()
+ const pointer = new Vector2()
+ // eslint-disable-next-line no-mixed-operators
+ pointer.x = ((screenX - canvasRect.left) / canvasRect.width) * 2 - 1
+ // eslint-disable-next-line no-mixed-operators
+ pointer.y = -((screenY - canvasRect.top) / canvasRect.height) * 2 + 1
+
+ raycaster.setFromCamera(pointer, camera)
+ const intersects = raycaster.intersectObjects(markerObjects)
+
+ // Assert that the raycaster intersects with the marker
+ expect(intersects.length).to.be.greaterThan(0)
+
+ cy.get('[data-testid="cadview-dropzone"]').then(($el) => {
+ const event = new MouseEvent('mousedown', {
+ bubbles: true,
+ cancelable: true,
+ clientX: screenX,
+ clientY: screenY,
+ })
+ $el[0].dispatchEvent(event)
+ })
+
+ // Assert that the URL hash contains marker coordinates
+ const expectedHash = `#m:${markerCoordinates[0]},${markerCoordinates[1]},${markerCoordinates[2]}`
+ cy.url().should('include', expectedHash)
+ })
+ })
+ })
+})
diff --git a/cypress/e2e/placemarks-100/marker-visibility.cy.js b/cypress/e2e/placemarks-100/marker-visibility.cy.js
new file mode 100644
index 000000000..8289a862c
--- /dev/null
+++ b/cypress/e2e/placemarks-100/marker-visibility.cy.js
@@ -0,0 +1,43 @@
+import '@percy/cypress'
+import {homepageSetup, returningUserVisitsHomepageWaitForModel} from '../../support/utils'
+
+
+/** {@link https://github.com/bldrs-ai/Share/issues/1054} */
+describe('Placemarks 100: Not visible when notes is not open', () => {
+ beforeEach(homepageSetup)
+ context('Returning user visits homepage', () => {
+ beforeEach(returningUserVisitsHomepageWaitForModel)
+ it('MarkerControl should not exist', () => {
+ cy.get('[data-testid="markerControl"]').should('not.exist')
+ })
+
+ context('Open Notes and MarkerControl should exist', () => {
+ let win
+ beforeEach(() => {
+ cy.get('[data-testid="control-button-notes"]').click()
+
+ cy.get('[data-testid="list-notes"]')
+ cy.get('[data-testid="panelTitle"]').contains('NOTES')
+
+ cy.window().then((window) => {
+ win = window
+ })
+ // eslint-disable-next-line cypress/no-unnecessary-waiting, no-magic-numbers
+ cy.wait(1500)
+ })
+ it('MarkerControl should exist', () => {
+ // Access the scene objects
+ const markers = win.markerScene.markerObjects
+
+ // Assert that markers exist
+ expect(markers.length).to.eq(2)
+
+ // Check visibility of markers
+ markers.forEach((marker) => {
+ // eslint-disable-next-line no-unused-expressions
+ expect(marker.userData.id).to.exist
+ })
+ })
+ })
+ })
+})
diff --git a/package.json b/package.json
index 35b1ad8ec..f94d28536 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bldrs",
- "version": "1.0.1133",
+ "version": "1.0.1151",
"main": "src/index.jsx",
"license": "AGPL-3.0",
"homepage": "https://github.com/bldrs-ai/Share",
diff --git a/src/Components/Markers/Marker.fixture.js b/src/Components/Markers/Marker.fixture.js
new file mode 100644
index 000000000..4ea89afd4
--- /dev/null
+++ b/src/Components/Markers/Marker.fixture.js
@@ -0,0 +1,18 @@
+export const MOCK_MARKERS = [
+ {
+ id: '14',
+ // eslint-disable-next-line no-magic-numbers
+ coordinates: [-47.076, 18.655, 0, 0, 0, 1],
+ isActive: false,
+ activeColor: 0xff0000,
+ inactiveColor: 0xa9a9a9,
+ },
+ {
+ id: '13',
+ // eslint-disable-next-line no-magic-numbers
+ coordinates: [-18, 20.289, -3.92, 1, 0, 0],
+ isActive: false,
+ activeColor: 0xff0000,
+ inactiveColor: 0xa9a9a9,
+ },
+ ]
diff --git a/src/Components/Markers/MarkerControl.jsx b/src/Components/Markers/MarkerControl.jsx
new file mode 100644
index 000000000..ab8616812
--- /dev/null
+++ b/src/Components/Markers/MarkerControl.jsx
@@ -0,0 +1,549 @@
+const OAUTH_2_CLIENT_ID = process.env.OAUTH2_CLIENT_ID
+import PropTypes from 'prop-types'
+import React, {useEffect} from 'react'
+import debug from '../../utils/debug'
+import {assertDefined} from '../../utils/assert'
+import {Vector3} from 'three'
+import {roundCoord} from '../../utils/math'
+import {setGroupColor} from '../../utils/svg'
+import {
+ batchUpdateHash,
+ getHashParams,
+ getHashParamsFromUrl,
+ removeParamsFromHash,
+ setParamsToHash,
+ stripHashParams,
+} from '../../utils/location'
+import {findMarkdownUrls} from '../../utils/strings'
+import {HASH_PREFIX_CAMERA} from '../../Components/Camera/hashState'
+import PlaceMark from '../../Infrastructure/PlaceMark'
+import {HASH_PREFIX_COMMENT, HASH_PREFIX_NOTES} from '../Notes/hashState'
+import useStore from '../../store/useStore'
+import {HASH_PREFIX_PLACE_MARK} from './hashState'
+
+
+/**
+ * MarkerControl Component
+ *
+ * Manages the creation, visibility, and selection of markers within the application.
+ * Handles setting the URL hash based on selected markers for navigation and linking.
+ *
+ * @param {object} props The properties passed to the component.
+ * @param {object} props.context The application context, typically containing configuration or state.
+ * @param {object} props.oppositeObjects An object containing references to objects opposite to the current focus.
+ * @param {Function} props.postProcessor A callback function for post-processing marker-related actions.
+ * @return {object} The MarkerControl component as a React element
+ */
+function MarkerControl({context, oppositeObjects, postProcessor}) {
+ assertDefined(context, oppositeObjects, postProcessor)
+
+ // eslint-disable-next-line new-cap
+ const {createPlaceMark} = PlacemarkHandlers()
+ const placeMark = useStore((state) => state.placeMark)
+ const isNotesVisible = useStore((state) => state.isNotesVisible)
+ const selectedPlaceMarkId = useStore((state) => state.selectedPlaceMarkId)
+ const markers = useStore((state) => state.markers)
+
+ useEffect(() => {
+ if (!selectedPlaceMarkId || !markers || markers.length === 0) {
+ return
+ }
+
+ // Find the marker with the matching selectedPlaceMarkId
+ const selectedMarker = markers.find((marker) =>
+ marker.id === selectedPlaceMarkId || marker.commentId === selectedPlaceMarkId,
+ )
+
+ if (selectedMarker) {
+ const {id, commentId, coordinates} = selectedMarker
+
+ // Construct the coordinates hash segment
+ const coordinatesHash = `#${HASH_PREFIX_PLACE_MARK}:${coordinates.join(',')}`
+
+ // Construct the issue/comment hash segment
+ const issueHash = `;${HASH_PREFIX_NOTES}:${id}${commentId ? `;${HASH_PREFIX_COMMENT}:${commentId}` : ''}`
+
+ // Combine both segments
+ const hash = `${coordinatesHash}${issueHash}`
+
+ // Set the location hash
+ window.location.hash = hash
+ } else {
+ // see if we have a temporary marker in the local group map
+ const temporaryMarker = placeMarkGroupMap.get(selectedPlaceMarkId)
+
+ if (temporaryMarker) {
+ window.location.hash = `#${selectedPlaceMarkId}`
+ }
+ }
+ }, [selectedPlaceMarkId, markers]) // Add markers as a dependency if it can change
+
+
+ // Toggle visibility of all placemarks based on isNotesVisible
+ useEffect(() => {
+ if (!placeMark) {
+ return
+ }
+
+ placeMark.getPlacemarks().forEach((placemark_) => {
+ placemark_.visible = isNotesVisible
+ })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isNotesVisible])
+
+ // Initialize PlaceMark instance
+ useEffect(() => {
+ if (!context) {
+ debug().error('MarkerControl: context is undefined')
+ return
+ }
+
+ if (placeMark) {
+ return
+ }
+
+ // Initialize PlaceMark and start render loop only once
+ const _placeMark = createPlaceMark({context, oppositeObjects, postProcessor})
+
+ // Only start render loop once
+ _placeMark.onRender()
+
+ return () => {
+ // Cleanup logic will be added here in the future if needed
+ }
+ })
+
+ // End of MarkerControl - no component returned
+ return null
+}
+
+export default React.memo(MarkerControl)
+
+
+/**
+ * @param {string} urlStr
+ * @return {string} The transformed URL
+ */
+export function modifyPlaceMarkHash(hash, _issueID, _commentID) {
+ if (hash && _issueID) {
+ let newHash = hash
+ let newURL = null
+ if (!hash.startsWith('#')) {
+ newURL = new URL(hash)
+ newHash = newURL.hash
+ }
+
+ if (newHash) {
+ newHash = removeParamsFromHash(newHash, HASH_PREFIX_NOTES) // Remove notes
+ newHash = removeParamsFromHash(newHash, HASH_PREFIX_COMMENT) // Remove comment
+ newHash = setParamsToHash(newHash, HASH_PREFIX_NOTES, {_issueID})
+
+ if (_commentID) {
+ newHash = setParamsToHash(newHash, HASH_PREFIX_COMMENT, {_commentID})
+ }
+ }
+
+ if (newURL) {
+ newURL.hash = newHash
+ return newURL.toString()
+ }
+
+ return newHash
+ }
+
+ return hash
+}
+
+
+/**
+ * Parses placemark URLs from an issue body.
+ *
+ * Extracts URLs that contain the specified placemark hash prefix from a given issue body.
+ *
+ * @param {string} issueBody The body of the issue to parse for placemark URLs.
+ * @return {string[]} An array of extracted placemark URLs.
+ */
+export function parsePlacemarkFromIssue(issueBody) {
+ return findMarkdownUrls(issueBody, HASH_PREFIX_PLACE_MARK)
+}
+
+/**
+ * Retrieves the active placemark hash from the current location.
+ *
+ * Extracts the hash associated with a placemark (based on the defined hash prefix)
+ * from the current window's location object.
+ *
+ * @return {string|null} The active placemark hash, or `null` if no hash is found.
+ */
+export function getActivePlaceMarkHash() {
+ return getHashParams(location, HASH_PREFIX_PLACE_MARK)
+}
+
+/**
+ * Extracts the placemark hash from a given URL.
+ *
+ * Parses a URL to extract the hash segment associated with a placemark,
+ * based on the defined hash prefix.
+ *
+ * @param {string} url The URL to parse for a placemark hash.
+ * @return {string|null} The extracted placemark hash, or `null` if no hash is found.
+ */
+export function parsePlacemarkFromURL(url) {
+ return getHashParamsFromUrl(url, HASH_PREFIX_PLACE_MARK)
+}
+
+/**
+ * Removes placemark parameters from the URL.
+ *
+ * This function removes any URL hash parameters associated with placemarks
+ * (identified by the placemark hash prefix) from the current browser window's location
+ * or a specified location object.
+ *
+ * @param {Location|null} location The location object to modify. If null, uses `window.location`.
+ * @return {string} The updated hash string with placemark parameters removed.
+ */
+export function removeMarkerUrlParams(location = null) {
+ return stripHashParams(location ? location : window.location, HASH_PREFIX_PLACE_MARK)
+}
+
+
+/**
+ * Place Mark Hook
+ *
+ * @return {Function}
+ */
+function PlacemarkHandlers() {
+ const placeMark = useStore((state) => state.placeMark)
+ const setPlaceMark = useStore((state) => state.setPlaceMark)
+ const placeMarkId = useStore((state) => state.placeMarkId)
+ const setPlaceMarkId = useStore((state) => state.setPlaceMarkId)
+ const setPlaceMarkActivated = useStore((state) => state.setPlaceMarkActivated)
+ const repository = useStore((state) => state.repository)
+ const notes = useStore((state) => state.notes)
+ const synchSidebar = useStore((state) => state.synchSidebar)
+ const toggleSynchSidebar = useStore((state) => state.toggleSynchSidebar)
+ const setPlaceMarkMode = useStore((state) => state.setPlaceMarkMode)
+ const isNotesVisible = useStore((state) => state.isNotesVisible)
+ const body = useStore((state) => state.body)
+ const setBody = useStore((state) => state.setBody)
+ const setEditBodyGlobal = useStore((state) => state.setEditBody)
+ const editBodies = useStore((state) => state.editBodies)
+ const editModes = useStore((state) => state.editModes)
+ // Access markers and the necessary store functions
+ const markers = useStore((state) => state.markers)
+ const setSelectedPlaceMarkId = useStore((state) => state.setSelectedPlaceMarkId)
+ const selectedPlaceMarkInNoteId = useStore((state) => state.selectedPlaceMarkInNoteId)
+
+ useEffect(() => {
+ if (!selectedPlaceMarkInNoteId) {
+ return
+ }
+ if (placeMarkGroupMap.size > 0) {
+ const _marker = placeMarkGroupMap.get(Number(selectedPlaceMarkInNoteId))
+
+ if (_marker) {
+ selectPlaceMarkMarker(_marker)
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedPlaceMarkInNoteId])
+
+ // Update the useEffect
+ useEffect(() => {
+ (async () => {
+ if (!placeMark || prevSynchSidebar === synchSidebar) {
+ return
+ }
+ prevSynchSidebar = synchSidebar
+
+ // dispose existing placemarks if they exist
+ placeMark.disposePlaceMarks()
+
+ placeMarkGroupMap.clear()
+
+ // Loop over markers to place each one in the scene
+ for (const marker of markers) {
+ if (!placeMarkGroupMap[marker.id]) {
+ const svgGroup = await placeMark.putDown({
+ point: new Vector3(marker.coordinates[0], marker.coordinates[1], marker.coordinates[2]),
+ normal: new Vector3(marker.coordinates[3], marker.coordinates[4], marker.coordinates[5]),
+ fillColor: marker.inactiveColor,
+ active: marker.isActive,
+ })
+
+ svgGroup.visible = isNotesVisible
+ svgGroup.userData.isActive = marker.isActive
+ svgGroup.userData.activeColor = marker.activeColor
+ svgGroup.userData.inactiveColor = marker.inactiveColor
+ svgGroup.userData.color = marker.isActive ? marker.activeColor : marker.inactiveColor
+ svgGroup.userData.id = marker.commentId ? marker.commentId : marker.id
+ // testing purposes
+ if (OAUTH_2_CLIENT_ID === 'cypresstestaudience') {
+ if (!window.markerScene) {
+ window.markerScene = {}
+ }
+ if (!window.markerScene.markerObjects) {
+ window.markerScene.markerObjects = []
+ }
+ window.markerScene.markerObjects.push(svgGroup)
+ }
+
+ placeMarkGroupMap.set(svgGroup.userData.id, svgGroup)
+ }
+ }
+
+
+ // eslint-disable-next-line no-unused-vars
+ for (const [_, value] of placeMarkGroupMap.entries()) {
+ if (value.userData.isActive) {
+ // set color to active if active
+ value.userData.color = value.userData.activeColor
+ value.material.color.set(value.userData.activeColor)
+ } else {
+ // set color to inactive
+ value.userData.isActive = false
+ value.userData.color = value.userData.inactiveColor
+ value.material.color.set(value.userData.inactiveColor)
+ }
+ }
+ })()
+ // Add markers to the dependency array so useEffect re-runs on markers change
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [markers])
+
+
+ // Save a placemark
+ const savePlaceMark = async ({point, normal, promiseGroup}) => {
+ if (point && normal && promiseGroup) {
+ const svgGroup = await promiseGroup
+ const positionData = roundCoord(...point)
+ const normalData = roundCoord(...normal)
+ const markArr = positionData.concat(normalData)
+
+ // Update location hash
+ batchUpdateHash(window.location, [
+ (hash) => setParamsToHash(hash, HASH_PREFIX_PLACE_MARK, markArr), // Add placemark
+ (hash) => removeParamsFromHash(hash, HASH_PREFIX_CAMERA), // Remove camera
+ (hash) => removeParamsFromHash(hash, HASH_PREFIX_NOTES), // Remove notes
+ (hash) => removeParamsFromHash(hash, HASH_PREFIX_COMMENT), // Remove comment
+ ])
+
+ // Add metadata to the temporary marker
+ const hash = getHashParamsFromUrl(window.location.href, HASH_PREFIX_PLACE_MARK)
+ const inactiveColor = 0xA9A9A9
+ const activeColor = 0xff0000
+ svgGroup.userData.isActive = false
+ svgGroup.userData.activeColor = activeColor
+ svgGroup.userData.inactiveColor = inactiveColor
+ svgGroup.userData.color = inactiveColor
+ svgGroup.material.color.set(inactiveColor)
+ svgGroup.userData.id = hash
+
+ placeMarkGroupMap.set(hash, svgGroup)
+ setPlaceMarkStatus(svgGroup, true)
+ }
+
+ setPlaceMarkMode(false)
+ placeMark.deactivate()
+ setPlaceMarkActivated(false)
+
+ if (!repository || !Array.isArray(notes)) {
+ return
+ }
+
+ const newNotes = [...notes]
+ const placeMarkNote = newNotes.find((note) => note.id === placeMarkId)
+ if (!placeMarkNote) {
+ return
+ }
+
+ // Get the current note body and append the new placemark link
+ const editMode = editModes?.[placeMarkNote.id]
+ const newCommentBody = `[placemark](${window.location.href})`
+ const currentBody = editMode ? (editBodies[placeMarkNote.id] || '') : (body || '') // Retrieve the existing body
+ const updatedBody = `${currentBody}\n${newCommentBody}`
+
+ // Set the updated body in the global store so NoteCard can use it
+ if (editMode) {
+ setEditBodyGlobal(placeMarkNote.id, updatedBody)
+ } else {
+ setBody(updatedBody) // Fallback to set the local body if not in edit mode
+ }
+
+ // Toggle the sidebar visibility after adding the placemark link
+ toggleSynchSidebar()
+ }
+
+ // Select a placemark
+ const selectPlaceMark = (res) => {
+ assertDefined(res.marker)
+
+ let foundKey = null
+
+ for (const [key, value] of placeMarkGroupMap.entries()) {
+ if (key === res.marker.userData.id) {
+ res.marker.userData.isActive = true
+ res.marker.userData.color = res.marker.userData.activeColor
+ res.marker.material.color.set(res.marker.userData.activeColor)
+ foundKey = key
+ } else {
+ value.userData.isActive = false
+ value.userData.color = value.userData.inactiveColor
+ value.material.color.set(value.userData.inactiveColor)
+ }
+ }
+
+ if (foundKey !== null) {
+ setSelectedPlaceMarkId(foundKey)
+ }
+ }
+
+ // Select a placemark
+ const selectPlaceMarkMarker = (marker) => {
+ assertDefined(marker)
+
+ let foundKey = null
+
+ for (const [key, value] of placeMarkGroupMap.entries()) {
+ if (key === marker.userData.id) {
+ marker.userData.isActive = true
+ marker.userData.color = marker.userData.activeColor
+ marker.material.color.set(marker.userData.activeColor)
+ foundKey = key
+ } else {
+ value.userData.isActive = false
+ value.userData.color = value.userData.inactiveColor
+ value.material.color.set(value.userData.inactiveColor)
+ }
+ }
+
+ if (foundKey !== null) {
+ setSelectedPlaceMarkId(foundKey)
+ }
+ }
+
+ const onSceneSingleTap = async (event, callback) => {
+ debug().log('usePlaceMark#onSceneSingleTap')
+ if (!placeMark) {
+ return
+ }
+ const res = placeMark.onSceneClick(event)
+
+ switch (event.button) {
+ case 0:
+ if (event.shiftKey) {
+ await savePlaceMark(res)
+ } else if (res.marker) {
+ selectPlaceMark(res)
+ }
+ break
+ // Add other cases as needed
+ default:
+ break
+ }
+
+ if (callback) {
+ callback(res)
+ }
+ }
+
+ const onSceneDoubleTap = async (event) => {
+ debug().log('usePlaceMark#onSceneDoubleTap')
+ if (!placeMark) {
+ return
+ }
+ const res = placeMark.onSceneDoubleClick(event)
+
+ switch (event.button) {
+ case 0:
+ await savePlaceMark(res)
+ break
+ // Add other cases as needed
+ default:
+ break
+ }
+ }
+ /**
+ *
+ */
+ function togglePlaceMarkActive(id) {
+ const deactivatePlaceMark = () => {
+ if (!placeMark) {
+ return
+ }
+ placeMark.deactivate()
+ setPlaceMarkActivated(false)
+ }
+
+ const activatePlaceMark = () => {
+ if (!placeMark) {
+ return
+ }
+ placeMark.activate()
+ setPlaceMarkActivated(true)
+ }
+
+ if (!placeMark) {
+ return
+ }
+
+ setPlaceMarkMode(true)
+
+ if (placeMarkId === id && placeMark.activated) {
+ deactivatePlaceMark()
+ } else {
+ activatePlaceMark()
+ }
+
+ setPlaceMarkId(id)
+ }
+
+ const createPlaceMark = ({context, oppositeObjects, postProcessor}) => {
+ debug().log('usePlaceMark#createPlaceMark')
+ const newPlaceMark = new PlaceMark({context, postProcessor})
+ newPlaceMark.setObjects(oppositeObjects)
+ setPlaceMark(newPlaceMark)
+ return newPlaceMark
+ }
+
+
+ return {createPlaceMark, onSceneDoubleTap, onSceneSingleTap, togglePlaceMarkActive}
+}
+
+
+const setPlaceMarkStatus = (svgGroup, isActive) => {
+ assertDefined(svgGroup, isActive)
+ resetPlaceMarksActive(false)
+ svgGroup.userData.isActive = isActive
+ resetPlaceMarkColors()
+}
+
+
+const resetPlaceMarksActive = (isActive) => {
+ placeMarkGroupMap.forEach((svgGroup) => {
+ svgGroup.userData.isActive = isActive
+ })
+}
+
+
+const resetPlaceMarkColors = () => {
+ placeMarkGroupMap.forEach((svgGroup) => {
+ let color = 'grey'
+ if (svgGroup.userData.isActive) {
+ color = 'red'
+ }
+ setGroupColor(svgGroup, color)
+ })
+}
+
+
+const placeMarkGroupMap = new Map()
+let prevSynchSidebar
+
+MarkerControl.propTypes = {
+ context: PropTypes.object.isRequired,
+ oppositeObjects: PropTypes.array.isRequired,
+ postProcessor: PropTypes.object,
+}
+
+export {PlacemarkHandlers}
diff --git a/src/Components/Markers/MarkerControl.test.jsx b/src/Components/Markers/MarkerControl.test.jsx
new file mode 100644
index 000000000..0176dcc34
--- /dev/null
+++ b/src/Components/Markers/MarkerControl.test.jsx
@@ -0,0 +1,205 @@
+import React from 'react'
+import {act, render, renderHook} from '@testing-library/react'
+import ShareMock from '../../ShareMock'
+import useStore from '../../store/useStore'
+import MarkerControl from './MarkerControl'
+import CadView from '../../Containers/CadView'
+import {MOCK_MARKERS} from './Marker.fixture'
+import {IfcViewerAPIExtended} from '../../Infrastructure/IfcViewerAPIExtended'
+import {makeTestTree} from '../../utils/TreeUtils.test'
+import {actAsyncFlush} from '../../utils/tests'
+import {Mesh, BoxGeometry, MeshBasicMaterial} from 'three'
+import {HASH_PREFIX_NOTES} from '../../Components/Notes/hashState'
+import {HASH_PREFIX_PLACE_MARK} from './hashState'
+
+
+window.HTMLElement.prototype.scrollIntoView = jest.fn()
+const mockedUseNavigate = jest.fn()
+const defaultLocationValue = {pathname: '/index.ifc', search: '', hash: '', state: null, key: 'default'}
+// mock createObjectURL
+global.URL.createObjectURL = jest.fn(() => '1111111111111111111111111111111111111111')
+
+jest.mock('../../OPFS/utils', () => {
+ const actualUtils = jest.requireActual('../../OPFS/utils')
+ const fs = jest.requireActual('fs')
+ const path = jest.requireActual('path')
+ const Blob = jest.requireActual('node:buffer').Blob
+
+ /**
+ * FileMock - Mocks File Web Interface
+ */
+ class FileMock {
+ /**
+ *
+ * @param {Blob} blobParts
+ * @param {string} fileName
+ * @param {any} options
+ */
+ constructor(blobParts, fileName, options) {
+ this.blobParts = blobParts
+ this.name = fileName
+ this.lastModified = options.lastModified || Date.now()
+ this.type = options.type
+ // Implement other properties and methods as needed for your tests
+ }
+
+ // Implement any required methods (e.g., slice, arrayBuffer, text) if your code uses them
+ }
+
+ return {
+ ...actualUtils, // Preserve other exports from the module
+ downloadToOPFS: jest.fn().mockImplementation(() => {
+ // Read the file content from disk
+ const fileContent = fs.readFileSync(path.join(__dirname, './index.ifc'), 'utf8')
+
+ const uint8Array = new Uint8Array(fileContent)
+ const blob = new Blob([uint8Array])
+
+ // The lastModified property is optional, and can be omitted or set to Date.now() if needed
+ const file = new FileMock([blob], 'index.ifc', {type: 'text/plain', lastModified: Date.now()})
+ // Return the mocked File in a promise if it's an async function
+ return Promise.resolve(file)
+ }),
+ downloadModel: jest.fn().mockImplementation(() => {
+ // Read the file content from disk
+ const fileContent = fs.readFileSync(path.join(__dirname, './index.ifc'), 'utf8')
+
+ const uint8Array = new Uint8Array(fileContent)
+ const blob = new Blob([uint8Array])
+
+ // The lastModified property is optional, and can be omitted or set to Date.now() if needed
+ const file = new FileMock([blob], 'index.ifc', {type: 'text/plain', lastModified: Date.now()})
+ // Return the mocked File in a promise if it's an async function
+ return Promise.resolve(file)
+ }),
+ }
+})
+
+
+jest.mock('react-router-dom', () => {
+ return {
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: () => mockedUseNavigate,
+ useLocation: jest.fn(() => defaultLocationValue),
+ }
+})
+jest.mock('postprocessing')
+jest.mock('@auth0/auth0-react', () => {
+ return {
+ ...jest.requireActual('@auth0/auth0-react'),
+ useAuth0: () => jest.fn(() => {
+ return {
+ isLoading: () => false,
+ isAuthenticated: () => false,
+ }
+ }),
+ }
+})
+
+describe('MarkerControl', () => {
+ let viewer
+
+ let originalWorker
+
+ beforeAll(() => {
+ // Store the original Worker in case other tests need it
+ originalWorker = global.Worker
+ })
+
+
+ // TODO: `document.createElement` can't be used in testing-library directly, need to move this after fixing that issue
+ beforeEach(() => {
+ viewer = new IfcViewerAPIExtended()
+ viewer._loadedModel.ifcManager.getSpatialStructure.mockReturnValue(makeTestTree())
+ viewer.context.getDomElement = jest.fn(() => {
+ return document.createElement('div')
+ })
+ })
+
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ global.Worker = originalWorker
+ })
+
+ // Properly mock viewer context
+ const mockCanvas = document.createElement('canvas')
+ const mockContext = {
+ getDomElement: jest.fn(() => mockCanvas), // Return the mocked canvas element
+ getCamera: jest.fn(() => ({
+ position: {x: 0, y: 0, z: 0},
+ })),
+ getScene: jest.fn(() => ({
+ children: [],
+ })),
+ }
+
+ // Create mock opposite objects
+const mockOppositeObjects = [
+ new Mesh(
+ new BoxGeometry(1, 1, 1),
+ new MeshBasicMaterial({color: 0x00ff00}),
+ ),
+ new Mesh(
+ new BoxGeometry(2, 2, 2),
+ new MeshBasicMaterial({color: 0xff0000}),
+ ),
+ ]
+ const mockPostProcessor = {}
+
+ beforeEach(async () => {
+ const {result} = renderHook(() => useStore((state) => state))
+ await act(() => result.current.setModelPath({filepath: `/index.ifc`}))
+ await act(() => {
+ result.current.writeMarkers([])
+ result.current.setSelectedPlaceMarkId(null)
+ })
+ })
+
+ it('Renders MarkerControl without crashing', async () => {
+ const {result} = renderHook(() => useStore((state) => state))
+ await act(() => result.current.setModelPath({filepath: `/index.ifc`}))
+ const {container} = render(
+
+
+
+ ,
+ )
+ await actAsyncFlush()
+ expect(container).toBeInTheDocument()
+ })
+
+ it('Updates the hash based on the selected placemark', async () => {
+ const {result} = renderHook(() => useStore((state) => state))
+ await act(() => result.current.setModelPath({filepath: `/index.ifc`}))
+ render(
+
+
+
+ ,
+ )
+
+ await actAsyncFlush()
+
+ await act(() => {
+ result.current.writeMarkers(MOCK_MARKERS)
+ })
+
+ await act(() => {
+ result.current.setSelectedPlaceMarkId(MOCK_MARKERS[0].id)
+ })
+
+ const {coordinates, id} = MOCK_MARKERS[0]
+ const expectedHash = `#${HASH_PREFIX_PLACE_MARK}:${coordinates.join(',')};${HASH_PREFIX_NOTES}:${id}`
+
+ expect(window.location.hash).toBe(expectedHash)
+ })
+})
diff --git a/src/Components/Markers/hashState.js b/src/Components/Markers/hashState.js
new file mode 100644
index 000000000..d817573c9
--- /dev/null
+++ b/src/Components/Markers/hashState.js
@@ -0,0 +1 @@
+export const HASH_PREFIX_PLACE_MARK = 'm'
diff --git a/src/Components/Notes/NoteBody.jsx b/src/Components/Notes/NoteBody.jsx
index e3124e90e..7a1d8914f 100644
--- a/src/Components/Notes/NoteBody.jsx
+++ b/src/Components/Notes/NoteBody.jsx
@@ -8,7 +8,7 @@ import NoteContent from './NoteContent'
* @property {string} markdownContent The note text in markdown format
* @return {ReactElement}
*/
-export default function NoteBody({selectCard, markdownContent}) {
+export default function NoteBody({selectCard, markdownContent, issueID, commentID}) {
return (
-
+
)
}
diff --git a/src/Components/Notes/NoteCard.jsx b/src/Components/Notes/NoteCard.jsx
index 2b2dcc79b..f3792e5cb 100644
--- a/src/Components/Notes/NoteCard.jsx
+++ b/src/Components/Notes/NoteCard.jsx
@@ -1,4 +1,4 @@
-import React, {ReactElement, useState, useEffect} from 'react'
+import React, {ReactElement, useState, useEffect, useRef} from 'react'
import Avatar from '@mui/material/Avatar'
import Card from '@mui/material/Card'
import CardHeader from '@mui/material/CardHeader'
@@ -22,7 +22,7 @@ import {
import {HASH_PREFIX_CAMERA} from '../Camera/hashState'
import NoteBody from './NoteBody'
import NoteContent from './NoteContent'
-import {HASH_PREFIX_NOTES} from './hashState'
+import {HASH_PREFIX_NOTES, HASH_PREFIX_COMMENT} from './hashState'
import NoteFooter from './NoteFooter'
import NoteMenu from './NoteMenu'
@@ -72,11 +72,46 @@ export default function NoteCard({
const [showCreateComment, setShowCreateComment] = useState(false)
+ const setEditModeGlobal = useStore((state) => state.setEditMode)
+ const editModes = useStore((state) => state.editModes)
+ const setEditBodyGlobal = useStore((state) => state.setEditBody)
+ const editBodies = useStore((state) => state.editBodies)
+
const [editMode, setEditMode] = useState(false)
const [editBody, setEditBody] = useState(body)
+
+ const handleEditBodyChange = (newBody) => {
+ setEditBody(newBody) // Update local editBody state
+ setEditBodyGlobal(id, newBody) // Update global editBody state
+ }
+
+
const {user} = useAuth0()
+ // Reference to the NoteCard element for scrolling
+ const noteCardRef = useRef(null)
+ const setActiveNoteCardId = useStore((state) => state.setActiveNoteCardId)
+
+ useEffect(() => {
+ setActiveNoteCardId(id)
+ return () => setActiveNoteCardId(null) // Reset when component unmounts
+ }, [id, setActiveNoteCardId])
+
+ // Sync local editMode with global editModes[id]
+ useEffect(() => {
+ if (editModes[id] !== undefined && editModes[id] !== editMode) {
+ setEditMode(editModes[id])
+ }
+ }, [editModes, id, editMode])
+
+ // Sync local editBody with global editBodies[id]
+ useEffect(() => {
+ if (editBodies[id] !== undefined && editBodies[id] !== editBody) {
+ setEditBody(editBodies[id])
+ }
+ }, [editBodies, id, editBody])
+
const embeddedCameraParams = findUrls(body)
.filter((url) => {
if (url.indexOf('#') === -1) {
@@ -135,6 +170,60 @@ export default function NoteCard({
setSnackMessage({text: 'The url path is copied to the clipboard', autoDismiss: true})
}
+ /** Copies location which contains the issue id, comment ID, camera position, and selected element path */
+ function shareComment(issueID, commentID, clearHash = true) {
+ // Get the current URL
+ const href = new URL(window.location.href)
+
+ // Initialize the hash components based on the clearHash flag
+ let updatedHash
+ if (clearHash) {
+ // Only include `i` and `gc` if clearHash is true
+ updatedHash = `${HASH_PREFIX_NOTES}:${issueID}`
+ if (commentID) {
+ updatedHash += `;${HASH_PREFIX_COMMENT}:${commentID}`
+ }
+ } else {
+ // Start with the existing hash (without the leading `#`)
+ const currentHash = href.hash.slice(1)
+
+ // Split the existing hash into parts based on `;`
+ const hashParts = currentHash ? currentHash.split(';') : []
+ const hashMap = {}
+
+ // Populate hashMap with existing values
+ hashParts.forEach((part) => {
+ const [key, value] = part.split(':')
+ if (key && value) {
+ hashMap[key] = value
+ }
+ })
+
+ // Set or update `i` and `gc` values in the hashMap
+ hashMap[HASH_PREFIX_NOTES] = issueID // Always set the issueID
+ if (commentID) {
+ hashMap[HASH_PREFIX_COMMENT] = commentID // Set commentID if it’s provided
+ }
+
+ // Reconstruct the hash string from hashMap
+ updatedHash = Object.entries(hashMap)
+ .map(([key, value]) => `${key}:${value}`)
+ .join(';')
+ }
+
+ // Update the URL hash with the newly constructed value
+ href.hash = updatedHash
+
+ // Copy the updated URL to the clipboard
+ navigator.clipboard.writeText(href.toString())
+ .then(() => {
+ setSnackMessage({text: 'The URL path is copied to the clipboard', autoDismiss: true})
+ })
+ .catch((err) => {
+ setSnackMessage({text: 'Failed to copy URL', autoDismiss: true})
+ })
+ }
+
/**
* Closes the issue. TODO(pablo): this isn't a delete
@@ -177,11 +266,12 @@ export default function NoteCard({
editedNote.body = res.data.body
setNotes(notes)
setEditMode(false)
+ setEditModeGlobal(id, false)
}
return (
-
+
{isNote ?
setEditMode(true)}
+ onEditClick={() => {
+ setEditMode(true)
+ setEditModeGlobal(id, true)
+ }}
onDeleteClick={() => onDeleteClick(noteNumber)}
noteNumber={noteNumber}
/>
@@ -201,12 +294,12 @@ export default function NoteCard({
subheader={`${username} at ${dateParts[0]} ${dateParts[1]}`}
/>}
{isNote && !editMode && !selected &&
- }
+ }
{selected && !editMode && }
- {!isNote && }
+ {!isNote && }
{editMode &&
setEditBody(event.target.value)}
+ handleTextUpdate={(event) => handleEditBodyChange(event.target.value)}
value={editBody}
isNote={isNote}
setShowCreateComment={setShowCreateComment}
@@ -223,7 +316,7 @@ export default function NoteCard({
noteNumber={noteNumber}
numberOfComments={numberOfComments}
onClickCamera={showCameraView}
- onClickShare={shareIssue}
+ onClickShare={isNote ? shareIssue : shareComment}
selectCard={selectCard}
selected={selected}
submitUpdate={submitUpdate}
diff --git a/src/Components/Notes/NoteCardCreate.jsx b/src/Components/Notes/NoteCardCreate.jsx
index 52c6140fb..b5769967e 100644
--- a/src/Components/Notes/NoteCardCreate.jsx
+++ b/src/Components/Notes/NoteCardCreate.jsx
@@ -14,6 +14,8 @@ import {createIssue, getIssueComments} from '../../net/github/Issues'
import {createComment} from '../../net/github/Comments'
import {assertStringNotEmpty} from '../../utils/assert'
import CheckIcon from '@mui/icons-material/Check'
+import PlaceMarkIcon from '../../assets/icons/PlaceMark.svg'
+import {PlacemarkHandlers as placemarkHandlers} from '../Markers/MarkerControl'
/**
@@ -39,8 +41,9 @@ export default function NoteCardCreate({
const selectedNoteId = useStore((state) => state.selectedNoteId)
const toggleIsCreateNoteVisible = useStore((state) => state.toggleIsCreateNoteVisible)
const [title, setTitle] = useState('')
- const [body, setBody] = useState(null)
-
+ const body = useStore((state) => state.body)
+ const setBody = useStore((state) => state.setBody)
+ const {togglePlaceMarkActive} = placemarkHandlers()
/**
* create issue takes in the title and body of the note from the state
@@ -156,31 +159,43 @@ export default function NoteCardCreate({
-
- {isNote ?
- }
- enabled={submitEnabled}
- size='small'
- placement='bottom'
- /> :
- }
- enabled={submitEnabled}
- size='small'
- placement='bottom'
- />
- }
-
+
+ {isNote ? (
+ }
+ enabled={submitEnabled}
+ size='small'
+ placement='bottom'
+ />
+ ) : (
+ <>
+ {
+ togglePlaceMarkActive(selectedNoteId)
+ }}
+ icon={}
+ />
+ }
+ enabled={submitEnabled}
+ size='small'
+ placement='bottom'
+ />
+ >
+ )}
+
)
diff --git a/src/Components/Notes/NoteContent.jsx b/src/Components/Notes/NoteContent.jsx
index f816e6e6b..5e6f10ee4 100644
--- a/src/Components/Notes/NoteContent.jsx
+++ b/src/Components/Notes/NoteContent.jsx
@@ -1,13 +1,18 @@
import React, {ReactElement, useMemo} from 'react'
import Markdown from 'react-markdown'
+import useStore from '../../store/useStore'
import CardContent from '@mui/material/CardContent'
-
+import {modifyPlaceMarkHash, parsePlacemarkFromURL} from '../Markers/MarkerControl'
+import {getHashParamsFromHashStr, getObjectParams} from '../../utils/location'
+import {HASH_PREFIX_NOTES, HASH_PREFIX_COMMENT} from './hashState'
/**
* @property {string} markdownContent The note text in markdown format
* @return {ReactElement}
*/
-export default function NoteContent({markdownContent}) {
+export default function NoteContent({markdownContent, issueID, commentID}) {
+ const setSelectedPlaceMarkInNoteId = useStore((state) => state.setSelectedPlaceMarkInNoteId)
+
/**
* @param {string} urlStr
* @return {string} The transformed URL
@@ -21,13 +26,60 @@ export default function NoteContent({markdownContent}) {
const noteContentLinksLocalized = useMemo(() => {
return markdownContent.replace(/\((https?:\/\/[^)]+)\)/g, (_, url) => {
- return `(${localizeUrl(url)})`
+ return `(${modifyPlaceMarkHash(localizeUrl(url), issueID, commentID)})`
})
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [markdownContent])
+ /**
+ * Handle hyperlink clicks
+ *
+ * @param {React.MouseEvent} event
+ */
+ const handleLinkClick = (event) => {
+ const urlStr = event.currentTarget.href
+
+ const placeMarkUrl = parsePlacemarkFromURL(urlStr)
+
+ if (placeMarkUrl) {
+ const url = new URL(urlStr)
+ const noteHash = getHashParamsFromHashStr(url.hash, HASH_PREFIX_NOTES)
+ // Retrieve the note ID from the URL hash
+ const commentHash = getHashParamsFromHashStr(url.hash, HASH_PREFIX_COMMENT)
+
+ if (commentHash) {
+ const params = Object.values(getObjectParams(`#${commentHash}`))
+
+ if (params) {
+ setSelectedPlaceMarkInNoteId(params[0])
+ event.preventDefault() // Prevent the default navigation
+ }
+ } else if (noteHash) {
+ const params = Object.values(getObjectParams(`#${noteHash}`))
+
+ if (params) {
+ setSelectedPlaceMarkInNoteId(params[0])
+ event.preventDefault() // Prevent the default navigation
+ }
+ }
+ }
+ }
+
return (
-
+ (
+
+ {children}
+
+ ),
+ }}
+ >
{noteContentLinksLocalized}
diff --git a/src/Components/Notes/NoteFooter.jsx b/src/Components/Notes/NoteFooter.jsx
index 1faad600c..22ebaa683 100644
--- a/src/Components/Notes/NoteFooter.jsx
+++ b/src/Components/Notes/NoteFooter.jsx
@@ -1,17 +1,18 @@
import React, {ReactElement, useState} from 'react'
import Box from '@mui/material/Box'
import CardActions from '@mui/material/CardActions'
-import useTheme from '@mui/styles/useTheme'
-import {useAuth0} from '../../Auth0/Auth0Proxy'
-import usePlaceMark from '../../hooks/usePlaceMark'
-import {useExistInFeature} from '../../hooks/useExistInFeature'
-import useStore from '../../store/useStore'
-import {TooltipIconButton} from '../Buttons'
import AddCommentOutlinedIcon from '@mui/icons-material/AddCommentOutlined'
import CheckIcon from '@mui/icons-material/Check'
+import CloseIcon from '@mui/icons-material/Close'
import ForumOutlinedIcon from '@mui/icons-material/ForumOutlined'
import GitHubIcon from '@mui/icons-material/GitHub'
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera'
+import useTheme from '@mui/styles/useTheme'
+import {useAuth0} from '../../Auth0/Auth0Proxy'
+import {useExistInFeature} from '../../hooks/useExistInFeature'
+import useStore from '../../store/useStore'
+import {TooltipIconButton} from '../Buttons'
+import {PlacemarkHandlers as placemarkHandlers} from '../Markers/MarkerControl'
import CameraIcon from '../../assets/icons/Camera.svg'
import PlaceMarkIcon from '../../assets/icons/PlaceMark.svg'
import ShareIcon from '../../assets/icons/Share.svg'
@@ -38,22 +39,24 @@ export default function NoteFooter({
synched,
username,
}) {
- const existPlaceMarkInFeature = useExistInFeature('placemark')
const isScreenshotEnabled = useExistInFeature('screenshot')
const viewer = useStore((state) => state.viewer)
const repository = useStore((state) => state.repository)
const placeMarkId = useStore((state) => state.placeMarkId)
const placeMarkActivated = useStore((state) => state.placeMarkActivated)
+ const setEditModeGlobal = useStore((state) => state.setEditMode)
const [shareIssue, setShareIssue] = useState(false)
const [screenshotUri, setScreenshotUri] = useState(null)
const {user} = useAuth0()
const theme = useTheme()
- const {togglePlaceMarkActive} = usePlaceMark()
const hasCameras = embeddedCameras.length > 0
+ const selectedNoteId = useStore((state) => state.selectedNoteId)
+
+ const {togglePlaceMarkActive} = placemarkHandlers()
/** Navigate to github issue */
function openGithubIssue() {
@@ -98,7 +101,20 @@ export default function NoteFooter({
/>
}
- {isNote && selected && synched && existPlaceMarkInFeature &&
+ {!isNote &&
+ {
+ onClickShare(selectedNoteId, id)
+ setShareIssue(!shareIssue)
+ }}
+ icon={}
+ />
+ }
+
+ {isNote && selected && synched &&
user && user.nickname === username &&
}
- {editMode &&
- }
- onClick={() => submitUpdate(repository, accessToken, id)}
- />
- }
+ {editMode && (
+ <>
+ }
+ onClick={() => submitUpdate(repository, accessToken, id)}
+ />
+ }
+ onClick={() => {
+ setEditModeGlobal(id, false) // Update global edit mode state
+ }}
+ />
+ >
+ )}
{isNote && !selected &&
state.repository)
const selectedNoteId = useStore((state) => state.selectedNoteId)
const setComments = useStore((state) => state.setComments)
+ // Access markers and the necessary store functions
+ const markers = useStore((state) => state.markers)
+ const writeMarkers = useStore((state) => state.writeMarkers)
+
+ const toggleSynchSidebar = useStore((state) => state.toggleSynchSidebar)
const [hasError, setHasError] = useState(false)
@@ -49,40 +57,131 @@ export default function Notes() {
}
// Fetch comments based on selected note id
- useEffect(() => {
- (async () => {
- try {
- if (!repository) {
- debug().warn('IssuesControl#Notes: 1, no repo defined')
- return
- }
- if (!selectedNoteId || !selectedNote) {
- return
- }
- const newComments = []
- const commentArr = await getIssueComments(repository, selectedNote.number, accessToken)
- debug().log('Notes#useEffect: commentArr: ', commentArr)
-
- if (commentArr) {
- commentArr.map((comment) => {
- newComments.push({
- id: comment.id,
- body: comment.body,
- date: comment.created_at,
- username: comment.user.login,
- avatarUrl: comment.user.avatar_url,
- synched: true,
- })
+useEffect(() => {
+ (async () => {
+ try {
+ if (!repository) {
+ debug().warn('IssuesControl#Notes: 1, no repo defined')
+ return
+ }
+ if (!selectedNoteId || !selectedNote) {
+ return
+ }
+
+ const newComments = []
+ const commentMarkers = [] // Array to store markers parsed from comments
+ const commentArr = await getIssueComments(repository, selectedNote.number, accessToken)
+ debug().log('Notes#useEffect: commentArr: ', commentArr)
+
+ // Get the main issue marker
+ const issueMarker = getMarkerById(selectedNoteId)
+
+ // Process each comment
+ if (commentArr) {
+ commentArr.forEach((comment) => {
+ newComments.push({
+ id: comment.id,
+ body: comment.body,
+ date: comment.created_at,
+ username: comment.user.login,
+ avatarUrl: comment.user.avatar_url,
+ synched: true,
})
- }
- setComments(newComments)
- } catch (e) {
- debug().warn('failed to fetch comments: ', e)
- handleError(e)
+
+ // Parse marker data from the comment
+ const commentMarker = parseComment(comment)
+ // Check if commentMarker is an array and has coordinates
+ if (Array.isArray(commentMarker) && commentMarker.length > 0) {
+ commentMarkers.push(...commentMarker) // Spread the array elements into commentMarkers
+ }
+ })
+ }
+
+ // Combine the issue marker and comment markers
+ const hasActiveMarker = commentMarkers.some((marker) => marker.isActive)
+
+ if (issueMarker) {
+ issueMarker.isActive = !(hasActiveMarker)
+ }
+ const allMarkers = issueMarker ? [issueMarker, ...commentMarkers] : commentMarkers
+
+ // Update state with new comments and markers
+ setComments(newComments)
+ toggleSynchSidebar()
+ writeMarkers(allMarkers) // Assuming `setMarkers` is a function in your store or component state to update markers
+ } catch (e) {
+ debug().warn('failed to fetch comments: ', e)
+ handleError(e)
+ }
+ })()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+}, [selectedNote])
+
+
+/**
+ * Parses a comment to extract placemark markers.
+ *
+ * This function processes the body of a comment to extract placemark URLs,
+ * generates marker data, and determines their active/inactive state.
+ *
+ * @param {object} comment The comment object containing the body and metadata.
+ * @param {string} comment.body The text of the comment to parse for placemark URLs.
+ * @param {number} comment.id The unique identifier for the comment.
+ * @return {object[]} An array of marker objects with coordinates and other properties.
+ */
+function parseComment(comment) {
+ const inactiveColor = 0xA9A9A9
+ const activeColor = 0xff0000
+ const issuePlacemarkUrls = parsePlacemarkFromIssue(comment.body)
+ let activePlaceMarkHash = getActivePlaceMarkHash()
+
+ // Accumulate markers for the current issue
+ const markers_ = issuePlacemarkUrls.map((url) => {
+ const hash = parsePlacemarkFromURL(url)
+ const newHash = `${hash};${HASH_PREFIX_NOTES}:${selectedNoteId};${HASH_PREFIX_COMMENT}:${comment.id}`
+ let isActive = false
+
+ const markArr = Object.values(getObjectParams(hash))
+ const lastElement = markArr[5].split(';')[0]
+
+ if (markArr.length === 6) {
+ const coordinates = [
+ parseFloat(markArr[0]),
+ parseFloat(markArr[1]),
+ parseFloat(markArr[2]),
+ parseFloat(markArr[3]),
+ parseFloat(markArr[4]),
+ parseFloat(lastElement),
+ ]
+
+ if (activePlaceMarkHash && hash.startsWith(activePlaceMarkHash)) {
+ activePlaceMarkHash = newHash
+ isActive = true
}
- })()
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedNote])
+
+ return {
+ id: selectedNoteId,
+ commentId: comment.id,
+ coordinates: coordinates,
+ isActive: isActive,
+ activeColor: activeColor,
+ inactiveColor: inactiveColor,
+ }
+ }
+ return null
+ }).filter(Boolean) // Filter out any null values
+
+ return markers_ // Return accumulated markers for the issue
+}
+
+ /**
+ * Gets a marker given a marker ID
+ *
+ * @return {object[]} An array of marker objects with coordinates and other properties.
+ */
+ function getMarkerById(id) {
+ return markers.find((marker) => marker.id === id)
+ }
return hasError ?
@@ -99,7 +198,7 @@ export default function Notes() {
{!selectedNoteId && !isCreateNoteVisible && notes && !isLoadingNotes &&
notes.map((note, index) => {
return (
-
+
- }
+
+ )}
{user && selectedNote && !selectedNote.locked && }
@@ -144,7 +245,7 @@ export default function Notes() {
{comments && selectedNote &&
comments.map((comment, index) => {
return (
-
+