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 ( - + { beforeEach(async () => { const {result} = renderHook(() => useStore((state) => state)) diff --git a/src/Components/Notes/NotesControl.jsx b/src/Components/Notes/NotesControl.jsx index c3744d2df..771388b5e 100644 --- a/src/Components/Notes/NotesControl.jsx +++ b/src/Components/Notes/NotesControl.jsx @@ -1,11 +1,12 @@ -import React, {ReactElement, useEffect} from 'react' +import React, {ReactElement, useEffect, useRef} from 'react' +import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined' import {getIssues} from '../../net/github/Issues' import useStore from '../../store/useStore' import debug from '../../utils/debug' -import {getHashParams} from '../../utils/location' +import {getHashParams, getObjectParams} from '../../utils/location' import {ControlButtonWithHashState} from '../Buttons' -import {HASH_PREFIX_NOTES} from './hashState' -import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined' +import {parsePlacemarkFromIssue, getActivePlaceMarkHash, parsePlacemarkFromURL} from '../Markers/MarkerControl' +import {HASH_PREFIX_NOTES, HASH_PREFIX_COMMENT} from './hashState' /** @@ -26,23 +27,33 @@ export default function NotesControl() { const setNotes = useStore((state) => state.setNotes) const setSelectedNoteId = useStore((state) => state.setSelectedNoteId) const toggleIsLoadingNotes = useStore((state) => state.toggleIsLoadingNotes) + const setSelectedCommentId = useStore((state) => state.setSelectedCommentId) + const selectedNoteId = useStore((state) => state.selectedNoteId) const setSnackMessage = useStore((state) => state.setSnackMessage) + const writeMarkers = useStore((state) => state.writeMarkers) + const toggleSynchSidebar = useStore((state) => state.toggleSynchSidebar) + const markers = useStore((state) => state.markers) + let activePlaceMarkHash = getActivePlaceMarkHash() + const inactiveColor = 0xA9A9A9 + const activeColor = 0xff0000 + // Fetch issues/notes useEffect(() => { if (isNotesVisible) { - // TODO(pablo): NotesControl loads onViewer, bc viewer for non-logged in - // session is valid. But! When the model is private, there's a delayed - // load until after auth succeeds. If we don't check model here, then Notes - // initially fails during an unauthenticated load via oauthproxy, which gets - // a 302 DIY, and somehow seems to keep that state in Octokit. - // - // We detect we're in a delayed load state here by checking model first, - // which then doesn't touch octokit until later when auth is available. if (!model) { return } + + // only want to get issues again if we are not in an issue thread. + if (selectedNoteId !== null && markers.length > 0) { + return + } + + // Clear markers each time useEffect is called + // writeMarkers(null) + (async () => { toggleIsLoadingNotes() try { @@ -51,7 +62,8 @@ export default function NotesControl() { const issueArr = await getIssues(repository, accessToken) debug().log('Notes#useEffect: issueArr: ', issueArr) - issueArr.reverse().map((issue, index) => { + // Accumulate markers from all issues + const allMarkers = issueArr.reverse().flatMap((issue, index) => { newNotes.push({ index: issueIndex++, id: issue.id, @@ -65,30 +77,208 @@ export default function NotesControl() { locked: issue.locked, synched: true, }) + + return parseMarker(issue) // Collect markers from this issue }) + const tempMarker = parseTempMarker(allMarkers) setNotes(newNotes) + toggleSynchSidebar() + writeMarkers(tempMarker ? [tempMarker, ...allMarkers] : allMarkers) toggleIsLoadingNotes() } catch (e) { setSnackMessage({text: 'Notes: Cannot fetch from GitHub', autoDismiss: true}) } })() } - // TODO(pablo): // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isNotesVisible, model, isCreateNoteVisible]) + }, [isNotesVisible, model, isCreateNoteVisible, selectedNoteId]) + + /** + * Parses a temporary marker if no markers are active + * + * @return {object[]} An array of marker objects with coordinates and other properties. + */ + function parseTempMarker(markers_) { + const hasActiveMarker = markers_.some((marker) => marker.isActive) + + if (!hasActiveMarker && activePlaceMarkHash) { + const markArr = Object.values(getObjectParams(activePlaceMarkHash)) + 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), + ] + + return { + id: 'temporary', + commentId: null, + coordinates: coordinates, + isActive: true, + activeColor: activeColor, + inactiveColor: inactiveColor, + } + } + } + + return null + } + + /** + * Parses marker from issue + * + * @return {object[]} An array of marker objects with coordinates and other properties. + */ + function parseMarker(issue) { + const issuePlacemarkUrls = parsePlacemarkFromIssue(issue.body) + + // Accumulate markers for the current issue + const markers_ = issuePlacemarkUrls.map((url) => { + const hash = parsePlacemarkFromURL(url) + const newHash = `${hash};${HASH_PREFIX_NOTES}:${issue.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 + } + + return { + id: issue.id, + commentId: null, + 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 + } + + + const selectedCommentId = useStore((state) => state.selectedCommentId) + const lastScrolledCommentId = useRef(null) + // Reference to the NoteCard element for scrolling + const activeNoteCardId = useStore((state) => state.activeNoteCardId) + + + /** + * Scrolls to a specific comment within the NoteCard component. + * + * @param {number} commentId - The ID of the comment to scroll to. + */ + function scrollToComment(commentId) { + const commentElement = document.querySelector(`[data-comment-id="${commentId}"]`) + if (commentElement) { + commentElement.scrollIntoView({behavior: 'smooth', block: 'center'}) + // Uncomment the following if camera position setting is required + // setCameraFromParams(firstCamera, cameraControls); + } + } + + /** + * Scrolls to a specific Note within the NoteCard component. + * + * @param {number} noteId - The ID of the comment to scroll to. + */ + function scrollToNote(noteId = -1) { + const noteElement = document.querySelector(`[data-note-id="${noteId === -1 ? selectedNoteId : noteId}"]`) + if (noteElement) { + noteElement.scrollIntoView({behavior: 'smooth', block: 'start'}) + // setCameraFromParams(firstCamera, cameraControls); // Set camera position if required + } + } + + useEffect(() => { + // Only proceed if `noteCardRef` is set and `selectedCommentId` has changed + if (selectedCommentId) { + if (selectedCommentId === -1) { + scrollToNote() + } else if (selectedCommentId) { + scrollToComment(selectedCommentId) + } + // Store the last scrolled-to comment ID + lastScrolledCommentId.current = selectedCommentId + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedCommentId, activeNoteCardId]) + useEffect(() => { + // When the selected note ID is set, scroll to that specific note + if (selectedNoteId) { + scrollToNote(selectedNoteId) + } + // eslint-disable-next-line react-hooks/exhaustive-deps +}, [selectedNoteId]) // TODO(pablo): hack, move into helper + // nickcastel50: this wasn't running if the hash changed - cleaned it up useEffect(() => { - const hashParams = getHashParams(window.location, HASH_PREFIX_NOTES) - if (hashParams) { - const parts = hashParams.split(':') - if (parts.length > 1) { - const id = parseInt(parts[1]) - setSelectedNoteId(id) + const updateIdsFromHash = () => { + // Retrieve the note ID from the URL hash + const noteHash = getHashParams(window.location, HASH_PREFIX_NOTES) + let noteId = null + if (noteHash) { + const noteParts = noteHash.split(':') + if (noteParts[0] === 'i' && noteParts[1]) { + noteId = parseInt(noteParts[1], 10) + } + } + + // Retrieve the comment ID from the URL hash + const commentHash = getHashParams(window.location, HASH_PREFIX_COMMENT) + let commentId = null + if (commentHash) { + const commentParts = commentHash.split(':') + if (commentParts[0] === HASH_PREFIX_COMMENT && commentParts[1]) { + commentId = parseInt(commentParts[1], 10) + } + } + + // Set selected note ID if a valid one is found + if (noteId) { + setSelectedNoteId(noteId) + } + + // Set selected comment ID if a valid one is found + if (commentId) { + setSelectedCommentId(commentId) + } else { + setSelectedCommentId(-1) } } - }, [setSelectedNoteId]) + + // Run on initial mount + updateIdsFromHash() + + // Listen for hash changes + window.addEventListener('hashchange', updateIdsFromHash) + + // Cleanup listener on unmount + return () => window.removeEventListener('hashchange', updateIdsFromHash) + }, [setSelectedNoteId, setSelectedCommentId]) + useEffect(() => { if (isNotesVisible === false) { diff --git a/src/Components/Notes/NotesControl.test.jsx b/src/Components/Notes/NotesControl.test.jsx index 1aebf45e5..e1fed088e 100644 --- a/src/Components/Notes/NotesControl.test.jsx +++ b/src/Components/Notes/NotesControl.test.jsx @@ -7,6 +7,9 @@ import NotesControl from './NotesControl' import model from '../../__mocks__/MockModel.js' +window.HTMLElement.prototype.scrollIntoView = jest.fn() + + describe('NotesControl', () => { it('Does not issue fetch on initial page load when not visible', async () => { const {result} = renderHook(() => useStore((state) => state)) @@ -32,7 +35,7 @@ describe('NotesControl', () => { await act(async () => { render() }) - expect(result.current.notes).toHaveLength(4) + expect(result.current.notes).toHaveLength(6) }) it('Fetches issues when isNotesVisible in zustand', async () => { @@ -50,7 +53,7 @@ describe('NotesControl', () => { await act(async () => { result.current.setIsNotesVisible(true) }) - expect(result.current.notes).toHaveLength(4) + expect(result.current.notes).toHaveLength(6) }) }) diff --git a/src/Components/Notes/NotesNavBar.jsx b/src/Components/Notes/NotesNavBar.jsx index aea1be2b6..b8b5dc3e3 100644 --- a/src/Components/Notes/NotesNavBar.jsx +++ b/src/Components/Notes/NotesNavBar.jsx @@ -1,10 +1,11 @@ import React, {ReactElement} from 'react' import Box from '@mui/material/Box' import useStore from '../../store/useStore' -import {setParams, removeParams} from '../../utils/location' +import {setParams, removeParams, removeParamsFromHash, setParamsToHash, batchUpdateHash} from '../../utils/location' import {CloseButton, TooltipIconButton} from '../Buttons' import {setCameraFromParams, addCameraUrlParams, removeCameraUrlParams} from '../Camera/CameraControl' -import {HASH_PREFIX_NOTES} from './hashState' +import {removeMarkerUrlParams} from '../Markers/MarkerControl' +import {HASH_PREFIX_COMMENT, HASH_PREFIX_NOTES} from './hashState' import AddCommentOutlinedIcon from '@mui/icons-material/AddCommentOutlined' import ArrowBackIcon from '@mui/icons-material/ArrowBack' import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore' @@ -21,6 +22,7 @@ export default function NotesNavBar() { const setSelectedNoteId = useStore((state) => state.setSelectedNoteId) const setSelectedNoteIndex = useStore((state) => state.setSelectedNoteIndex) const toggleIsCreateNoteVisible = useStore((state) => state.toggleIsCreateNoteVisible) + const setSelectedPlaceMarkId = useStore((state) => state.setSelectedPlaceMarkId) /** @@ -77,8 +79,15 @@ export default function NotesNavBar() { { - setParams(HASH_PREFIX_NOTES) + setSelectedPlaceMarkId(null) setSelectedNoteId(null) + const _location = window.location + batchUpdateHash(_location, [ + (hash) => removeMarkerUrlParams({hash}), // Remove marker params + (hash) => removeParamsFromHash(hash, HASH_PREFIX_NOTES), // Remove notes params + (hash) => removeParamsFromHash(hash, HASH_PREFIX_COMMENT), // Remove comment params + (hash) => setParamsToHash(hash, HASH_PREFIX_NOTES), // Add notes params + ]) }} icon={} variant='noBackground' diff --git a/src/Components/Notes/hashState.js b/src/Components/Notes/hashState.js index cd45a6f0f..e46151cf4 100644 --- a/src/Components/Notes/hashState.js +++ b/src/Components/Notes/hashState.js @@ -3,6 +3,7 @@ import {hasParams} from '../../utils/location' /** The prefix to use for the Note state tokens */ export const HASH_PREFIX_NOTES = 'i' +export const HASH_PREFIX_COMMENT = 'ic' /** @return {boolean} */ diff --git a/src/Components/OperationsGroup.jsx b/src/Components/OperationsGroup.jsx index 4a962f4a0..5c60104af 100644 --- a/src/Components/OperationsGroup.jsx +++ b/src/Components/OperationsGroup.jsx @@ -4,6 +4,7 @@ import Divider from '@mui/material/Divider' import useStore from '../store/useStore' import {TooltipIconButton} from './Buttons' import CameraControl from './Camera/CameraControl' +import MarkerControl from '../Components/Markers/MarkerControl' import ImagineControl from './Imagine/ImagineControl' import NotesControl from './Notes/NotesControl' import ProfileControl from './Profile/ProfileControl' @@ -31,6 +32,11 @@ export default function OperationsGroup({deselectItems}) { const toggleAppStoreDrawer = useStore((state) => state.toggleAppStoreDrawer) const isAnElementSelected = selectedElement !== null + // required for MarkerControl + const viewer = useStore((state) => state.viewer) + const isModelReady = useStore((state) => state.isModelReady) + const model = useStore((state) => state.model) + return ( {isLoginEnabled && ( @@ -50,6 +56,14 @@ export default function OperationsGroup({deselectItems}) { onClick={() => toggleAppStoreDrawer()} /> } + {(viewer && isModelReady) && ( + + )} {isImagineEnabled && } {/* Invisible */} diff --git a/src/Components/OperationsGroup.test.jsx b/src/Components/OperationsGroup.test.jsx index f66cf48d5..6accbfe84 100644 --- a/src/Components/OperationsGroup.test.jsx +++ b/src/Components/OperationsGroup.test.jsx @@ -5,6 +5,9 @@ import ShareMock from '../ShareMock' import useStore from '../store/useStore' import OperationsGroup from './OperationsGroup' + +window.HTMLElement.prototype.scrollIntoView = jest.fn() + // Instantiates ImagineControl which uses viewer's screenshot function // jest.mock('web-ifc-viewer') diff --git a/src/Containers/CadView.jsx b/src/Containers/CadView.jsx index 1d0224f75..c51248693 100644 --- a/src/Containers/CadView.jsx +++ b/src/Containers/CadView.jsx @@ -13,7 +13,6 @@ import HelpControl from '../Components/Help/HelpControl' import {useIsMobile} from '../Components/Hooks' import LoadingBackdrop from '../Components/LoadingBackdrop' import {getModelFromOPFS, downloadToOPFS, downloadModel} from '../OPFS/utils' -import usePlaceMark from '../hooks/usePlaceMark' import * as Analytics from '../privacy/analytics' import useStore from '../store/useStore' // TODO(pablo): use ^^ instead of this @@ -115,8 +114,6 @@ export default function CadView({ // Begin Hooks // const isMobile = useIsMobile() const location = useLocation() - // Place Mark - const {createPlaceMark} = usePlaceMark() // Auth const {isLoading: isAuthLoading, isAuthenticated} = useAuth0() const setOpfsFile = useStore((state) => state.setOpfsFile) @@ -203,11 +200,6 @@ export default function CadView({ setSnackMessage(null) debug().log('CadView#onViewer: tmpModelRef: ', tmpModelRef) await onModel(tmpModelRef) - createPlaceMark({ - context: viewer.context, - oppositeObjects: [tmpModelRef], - postProcessor: viewer.postProcessor, - }) selectElementBasedOnFilepath(pathToLoad) // maintain hidden elements if any const previouslyHiddenELements = Object.entries(useStore.getState().hiddenElements) @@ -540,7 +532,9 @@ export default function CadView({ const firstId = resultIDs.slice(0, 1) const pathIds = getParentPathIdsForElement(elementsById, parseInt(firstId)) const repoFilePath = modelPath.gitpath ? modelPath.getRepoPath() : modelPath.filepath - const path = pathIds.join('/') + const enabledFeatures = searchParams.get('feature') + const pathIDsStr = pathIds.join('/') + const path = enabledFeatures ? `${pathIDsStr }?feature=${ enabledFeatures}` : pathIDsStr navWith( navigate, `${pathPrefix}${repoFilePath}/${path}`, diff --git a/src/Containers/CadView.test.jsx b/src/Containers/CadView.test.jsx index 49af2c0ba..4889b797b 100644 --- a/src/Containers/CadView.test.jsx +++ b/src/Containers/CadView.test.jsx @@ -13,6 +13,7 @@ import CadView from './CadView' import PkgJson from '../../package.json' +window.HTMLElement.prototype.scrollIntoView = jest.fn() const bldrsVersionString = `Bldrs: ${PkgJson.version}` const mockedUseNavigate = jest.fn() const defaultLocationValue = {pathname: '/index.ifc', search: '', hash: '', state: null, key: 'default'} diff --git a/src/Containers/ViewerContainer.jsx b/src/Containers/ViewerContainer.jsx index e85cbb189..3c5122ef7 100644 --- a/src/Containers/ViewerContainer.jsx +++ b/src/Containers/ViewerContainer.jsx @@ -1,20 +1,19 @@ import React, {ReactElement, useState} from 'react' import Box from '@mui/material/Box' -import usePlaceMark from '../hooks/usePlaceMark' import {useNavigate} from 'react-router-dom' -import {loadLocalFileDragAndDrop} from '../OPFS/utils' +import {PlacemarkHandlers as placemarkHandlers} from '../Components/Markers/MarkerControl' import useStore from '../store/useStore' -import {loadLocalFileDragAndDropFallback} from '../utils/loader' +import {loadLocalFileDragAndDrop} from '../OPFS/utils' import {handleBeforeUnload} from '../utils/event' +import {loadLocalFileDragAndDropFallback} from '../utils/loader' /** @return {ReactElement} */ export default function ViewerContainer() { - const {onSceneSingleTap, onSceneDoubleTap} = usePlaceMark() - const appPrefix = useStore((state) => state.appPrefix) const isModelReady = useStore((state) => state.isModelReady) const isOpfsAvailable = useStore((state) => state.isOpfsAvailable) + const {onSceneSingleTap, onSceneDoubleTap} = placemarkHandlers() const [, setIsDragActive] = useState(false) @@ -74,7 +73,7 @@ export default function ViewerContainer() { textAlign: 'center', }} onMouseDown={async (event) => await onSceneSingleTap(event)} - {...onSceneDoubleTap} + onDoubleClick={async (event) => await onSceneDoubleTap(event)} onDragOver={handleDragOverOrEnter} onDragEnter={handleDragOverOrEnter} onDragLeave={handleDragLeave} diff --git a/src/Infrastructure/PlaceMark.js b/src/Infrastructure/PlaceMark.js index ef8253467..6d5f405d4 100644 --- a/src/Infrastructure/PlaceMark.js +++ b/src/Infrastructure/PlaceMark.js @@ -1,119 +1,69 @@ +const OAUTH_2_CLIENT_ID = process.env.OAUTH2_CLIENT_ID import { EventDispatcher, - Mesh, + Sprite, + SpriteMaterial, + CanvasTexture, Vector2, - Vector3, Raycaster, + Matrix3, } from 'three' -import {IfcContext} from 'web-ifc-viewer/dist/components' -import {floatStrTrim} from '../utils/strings' -import {disposeGroup, getSvgGroupFromObj, getSvgObjFromUrl} from '../utils/svg' import {isDevMode} from '../utils/common' -import {BlendFunction} from 'postprocessing' +import {floatStrTrim} from '../utils/strings' /** - * PlaceMark to share notes + * Class representing a PlaceMark in a 3D scene. + * Handles creation, rendering, occlusion detection, and selection of placemarks. */ export default class PlaceMark extends EventDispatcher { /** - * @param {IfcContext} context + * Creates a new PlaceMark instance. + * + * @param {object} options - Options for the PlaceMark. + * @param {object} options.context - Rendering context providing access to DOM element, camera, and scene. + * @param {object} options.postProcessor - Post-processing effects applied to the scene. */ constructor({context, postProcessor}) { super() const _domElement = context.getDomElement() const _camera = context.getCamera() + // Assign the camera to the global window object for Cypress testing + if (OAUTH_2_CLIENT_ID === 'cypresstestaudience') { + if (!window.markerScene) { + window.markerScene = {} + } + window.markerScene.domElement = _domElement + window.markerScene.camera = _camera + } const _scene = context.getScene() - const _pointer = new Vector2() + const _raycaster = new Raycaster() let _objects = [] const _placeMarks = [] - const _raycaster = new Raycaster() - const outlineEffect = postProcessor.createOutlineEffect({ - blendFunction: BlendFunction.SCREEN, - edgeStrength: 1.5, - pulseSpeed: 0.0, - visibleEdgeColor: 0xc7c7c7, - hiddenEdgeColor: 0xff9b00, - height: window.innerHeight, - windth: window.innerWidth, - blur: false, - xRay: true, - opacity: 1, - }) - const composer = postProcessor.getComposer - this.activated = false - _domElement.style.touchAction = 'none' // disable touch scroll - + _domElement.style.touchAction = 'none' + const _pointer = new Vector2() this.activate = () => { this.activated = true _domElement.style.cursor = 'alias' } - this.deactivate = () => { this.activated = false _domElement.style.cursor = 'default' } - this.setObjects = (objects) => { - _objects = objects - } - - - this.onSceneDoubleClick = (event) => { - let res = {} - - switch (event.button) { - case 0: // Main button (left button) - res = dropPlaceMark(event) - break - case 1: // Wheel button (middle button if present) - break - case 2: // Secondary button (right button) - break - case 3: // Fourth button (back button) - break - case 4: // Fifth button (forward button) - break - default: - break + if (!Array.isArray(objects) || objects.length === 0) { + // eslint-disable-next-line no-console + console.error('PlaceMark#setObjects: \'objects\' must be a non-empty array.') + return } - - return res - } - - - this.onSceneClick = (event) => { - let res = {} - - switch (event.button) { - case 0: // Main button (left button) - if (event.shiftKey) { - res = dropPlaceMark(event) - } else { - res = getIntersectionPlaceMarkInfo() - } - break - case 1: // Wheel button (middle button if present) - break - case 2: // Secondary button (right button) - break - case 3: // Fourth button (back button) - break - case 4: // Fifth button (forward button) - break - default: - break - } - - return res + _objects = objects } - const dropPlaceMark = (event) => { let res = {} if (isDevMode()) { @@ -123,132 +73,201 @@ export default class PlaceMark extends EventDispatcher { if (_objects && this.activated) { updatePointer(event) const _intersections = [] - _intersections.length = 0 _raycaster.setFromCamera(_pointer, _camera) _raycaster.intersectObjects(_objects, true, _intersections) if (_intersections.length > 0) { - const intersectPoint = _intersections[0].point + const intersect = _intersections[0] + const intersectPoint = intersect.point.clone() intersectPoint.x = floatStrTrim(intersectPoint.x) intersectPoint.y = floatStrTrim(intersectPoint.y) intersectPoint.z = floatStrTrim(intersectPoint.z) - const offset = _intersections[0].face.normal.clone().multiplyScalar(PLACE_MARK_DISTANCE) - const point = intersectPoint.add(offset) - const lookAt = point.add(_intersections[0].face.normal) - const promiseGroup = this.putDown({point, lookAt}) - res = {point, lookAt, promiseGroup} + + if (intersect.face && intersect.object) { + const normal = intersect.face.normal.clone().applyMatrix3(new Matrix3().getNormalMatrix(intersect.object.matrixWorld)) + const offset = normal.clone().multiplyScalar(PLACE_MARK_DISTANCE) + const point = intersectPoint.add(offset) + const promiseGroup = this.putDown({point, normal, active: false}) + + res = {point, normal, promiseGroup} + } } } return res } + this.onSceneDoubleClick = (event) => { + let res = {} + + if (event.button === 0) { + res = dropPlaceMark(event) + } + + return res + } + + this.onSceneClick = (event) => { + let res = {} + + if (event.button === 0) { + if (event.shiftKey) { + res = dropPlaceMark(event) + } else { + res = getIntersectionPlaceMarkInfo() + /* if (res.marker) { + toggleMarkerSelection(res.marker) + event.stopPropagation() + event.preventDefault() + }*/ + } + } + + return res + } - this.putDown = ({point, lookAt, fillColor = 'black', height = INACTIVE_PLACE_MARK_HEIGHT}) => { + this.putDown = ({point, normal, fillColor = 0xA9A9A9/* 0xff0000*/, active}) => { return new Promise((resolve, reject) => { - getSvgObjFromUrl('/icons/PlaceMark.svg').then((svgObj) => { - const _placeMark = getSvgGroupFromObj({svgObj, fillColor, layer: 'placemark', height}) - _placeMark.position.copy(point) - _scene.add(_placeMark) - _placeMarks.push(_placeMark) - const placeMarkMeshSet = getPlaceMarkMeshSet() - outlineEffect.setSelection(placeMarkMeshSet) - resolve(_placeMark) - }) + if (!normal) { + reject(new Error('Normal vector is not defined.')) + return + } + const _placeMark = createCirclePlacemark(point, fillColor) + + // if (active) { + // toggleMarkerSelection(_placeMark) + // } + resolve(_placeMark) }) } + this.disposePlaceMarks = () => { + // Remove all place marks from the scene + if (_placeMarks) { + _placeMarks.forEach((placemark) => { + _scene.remove(placemark) + if (placemark.material.map) { + placemark.material.map.dispose() + } + placemark.material.dispose() + }) + _placeMarks.length = 0 + + // Dispose of any other resources if necessary + } + } this.disposePlaceMark = (_placeMark) => { const index = _placeMarks.indexOf(_placeMark) if (index > -1) { _placeMarks.splice(index, 1) - disposeGroup(_placeMark) _scene.remove(_placeMark) } } + /** + * Returns all active placemarks. + * + * @return {Array} Array of placemark objects. + */ + this.getPlacemarks = () => { + return _placeMarks + } const updatePointer = (event) => { const rect = _domElement.getBoundingClientRect() - _pointer.x = (((event.clientX - rect.left) / rect.width) * 2) - 1 - _pointer.y = ((-(event.clientY - rect.top) / rect.height) * 2) + 1 + // eslint-disable-next-line no-mixed-operators + _pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 + // eslint-disable-next-line no-mixed-operators + _pointer.y = (-(event.clientY - rect.top) / rect.height) * 2 + 1 } - const getIntersectionPlaceMarkInfo = () => { let res = {} if (_placeMarks.length) { updatePointer(event) const _intersections = [] - _intersections.length = 0 _raycaster.setFromCamera(_pointer, _camera) _raycaster.intersectObjects(_placeMarks, true, _intersections) if (_intersections.length) { - res = {url: _intersections[0].object?.userData?.url} + res = {marker: _intersections[0].object} } } return res } - - const getPlaceMarkMeshSet = () => { - const placeMarkMeshSet = new Set() - _placeMarks.forEach((placeMark) => { - placeMark.traverse((child) => { - if (child instanceof Mesh) { - placeMarkMeshSet.add(child) - } - }) + const createCirclePlacemark = (position, fillColor) => { + const texture = createCircleTexture(fillColor) + const material = new SpriteMaterial({ + map: texture, + transparent: true, + depthTest: false, // Disable depth testing }) - return placeMarkMeshSet + const placemark = new Sprite(material) + placemark.position.copy(position) + placemark.renderOrder = 999 // High render order to ensure it's drawn last + placemark.material.color.set(fillColor) + _scene.add(placemark) + _placeMarks.push(placemark) + // toggleMarkerSelection(placemark) + return placemark } - - const newRendererUpdate = () => { - /** - * Overrides the default update function in the context renderer - * - * @param {number} _delta - */ - function newUpdateFn(_delta) { - if (!context) { - return - } - - _placeMarks.forEach((_placeMark) => { - _placeMark.quaternion.copy(_camera.quaternion) - const dist = _placeMark.position.distanceTo(_camera.position) - const sideScale = dist / PLACE_MARK_SCALE_FACTOR - tempScale.set(sideScale, sideScale, sideScale) - if (_placeMark.userData.isActive) { - tempScale.multiplyScalar(ACTIVE_PLACE_MARK_SCALE) - } - _placeMark.scale.copy(tempScale) - }) - - composer.render() - } - - - return newUpdateFn.bind(context.renderer) + const createCircleTexture = (fillColor) => { + const size = 64 // Texture size in pixels + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const canvasContext = canvas.getContext('2d') + + // Ensure the entire canvas is transparent initially + canvasContext.clearRect(0, 0, size, size) + + // Draw the circle + canvasContext.beginPath() + // eslint-disable-next-line no-mixed-operators + canvasContext.arc(size / 2, size / 2, size / 2 - 2, 0, Math.PI * 2) // -2 for a slight border + // eslint-disable-next-line no-magic-numbers + canvasContext.fillStyle = `#${fillColor.toString(16).padStart(6, '0')}` + canvasContext.fill() + + // Optionally add a border + canvasContext.lineWidth = 2 + canvasContext.strokeStyle = '#000000' + canvasContext.stroke() + + return new CanvasTexture(canvas) } + /* const toggleMarkerSelection = (marker) => { + _selectedPlaceMarks.forEach((selectedMarker) => { + // eslint-disable-next-line no-magic-numbers + selectedMarker.material.color.set(0xA9A9A9) + }) + _selectedPlaceMarks.clear() + _selectedPlaceMarks.add(marker) + // eslint-disable-next-line no-magic-numbers + marker.material.color.set(0xff0000) + }*/ + + const updatePlacemarksVisibility = () => { + _placeMarks.forEach((placemark) => { + placemark.scale.set(PLACEMARK_SIZE, PLACEMARK_SIZE, PLACEMARK_SIZE) + }) + } - if (context.renderer) { - // eslint-disable-next-line max-len - // This patch applies to https://github.com/IFCjs/web-ifc-viewer/blob/9ce3a42cb8d4ffd5b78b19d56f3b4fad2d1f3c0e/viewer/src/components/context/renderer/renderer.ts#L44 - context.renderer.update = newRendererUpdate() + this.onRender = () => { + updatePlacemarksVisibility() + _placeMarks.sort((a, b) => a.position.distanceTo(_camera.position) - b.position.distanceTo(_camera.position)) + requestAnimationFrame(this.onRender) } + + requestAnimationFrame(this.onRender) } } - -const tempScale = new Vector3() +const PLACEMARK_SIZE = 2.5 const PLACE_MARK_DISTANCE = 0 -const INACTIVE_PLACE_MARK_HEIGHT = 1 -const ACTIVE_PLACE_MARK_SCALE = 1.6 -const PLACE_MARK_SCALE_FACTOR = 60 diff --git a/src/hooks/usePlaceMark.js b/src/hooks/usePlaceMark.js deleted file mode 100644 index 305efad9b..000000000 --- a/src/hooks/usePlaceMark.js +++ /dev/null @@ -1,322 +0,0 @@ -import {useEffect} from 'react' -import {useLocation} from 'react-router-dom' -import {Vector3} from 'three' -import {useDoubleTap} from 'use-double-tap' -import useStore from '../store/useStore' -import PlaceMark from '../Infrastructure/PlaceMark' -import { - addHashParams, - getAllHashParams, - getHashParams, - getHashParamsFromUrl, - getObjectParams, - removeHashParams, -} from '../utils/location' -import {HASH_PREFIX_CAMERA} from '../Components/Camera/hashState' -import {floatStrTrim, findMarkdownUrls} from '../utils/strings' -import {roundCoord} from '../utils/math' -import {addUserDataInGroup, setGroupColor} from '../utils/svg' -import {getIssueComments, getIssues} from '../net/github/Issues' -import {createComment} from '../net/github/Comments' -import {arrayDiff} from '../utils/arrays' -import {assertDefined} from '../utils/assert' -import {isDevMode} from '../utils/common' -import {useExistInFeature} from './useExistInFeature' -import debug from '../utils/debug' - - -/** - * Place Mark Hook - * - * @return {Function} - */ -export default function usePlaceMark() { - 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 accessToken = useStore((state) => state.accessToken) - const synchSidebar = useStore((state) => state.synchSidebar) - const toggleSynchSidebar = useStore((state) => state.toggleSynchSidebar) - const location = useLocation() - const existPlaceMarkInFeature = useExistInFeature('placemark') - - - useEffect(() => { - (async () => { - debug().log('usePlaceMark#useEffect[synchSidebar]: repository: ', repository) - debug().log('usePlaceMark#useEffect[synchSidebar]: placeMark: ', placeMark) - debug().log('usePlaceMark#useEffect[synchSidebar]: prevSynchSidebar: ', prevSynchSidebar) - debug().log('usePlaceMark#useEffect[synchSidebar]: synchSidebar: ', synchSidebar) - debug().log('usePlaceMark#useEffect[synchSidebar]: existPlaceMarkInFeature: ', existPlaceMarkInFeature) - if (!repository || !placeMark || prevSynchSidebar === synchSidebar || !existPlaceMarkInFeature) { - return - } - prevSynchSidebar = synchSidebar - const issueArr = await getIssues(repository, accessToken) - - const promises1 = issueArr.map(async (issue) => { - const issueComments = await getIssueComments(repository, issue.number, accessToken) - let placeMarkUrls = [] - - issueComments.forEach((comment) => { - if (comment.body) { - const newPlaceMarkUrls = findMarkdownUrls(comment.body, HASH_PREFIX_PLACE_MARK) - placeMarkUrls = placeMarkUrls.concat(newPlaceMarkUrls) - } - }) - - return placeMarkUrls - }) - - const totalPlaceMarkUrls = (await Promise.all(promises1)).flat() - const totalPlaceMarkHashUrlMap = new Map() - - totalPlaceMarkUrls.forEach((url) => { - const hash = getHashParamsFromUrl(url, HASH_PREFIX_PLACE_MARK) - totalPlaceMarkHashUrlMap.set(hash, url) - }) - - const totalPlaceMarkHashes = Array.from(totalPlaceMarkHashUrlMap.keys()) - const activePlaceMarkHash = getHashParams(location, HASH_PREFIX_PLACE_MARK) - const inactivePlaceMarkHashes = totalPlaceMarkHashes.filter((hash) => hash !== activePlaceMarkHash) - - if (activePlaceMarkHash) { - const activeGroup = placeMarkGroupMap.get(activePlaceMarkHash) - - if (activeGroup) { - addUserDataInGroup(activeGroup, {isActive: true}) - } else { - // Drop active place mark mesh if it's not existed in scene - const markArr = getObjectParams(activePlaceMarkHash) - const svgGroup = await placeMark.putDown({ - point: new Vector3(floatStrTrim(markArr[0]), floatStrTrim(markArr[1]), floatStrTrim(markArr[2])), - }) - addUserDataInGroup(svgGroup, {url: window.location.href, isActive: true}) - placeMarkGroupMap.set(activePlaceMarkHash, svgGroup) - } - } - - const promises2 = inactivePlaceMarkHashes.map(async (hash) => { - const svgGroup = placeMarkGroupMap.get(hash) - if (svgGroup) { - addUserDataInGroup(svgGroup, {isActive: false}) - } else { - // Drop inactive place mark mesh if it's not existed in scene - const markArr = getObjectParams(hash) - const newSvgGroup = await placeMark.putDown({ - point: new Vector3(floatStrTrim(markArr[0]), floatStrTrim(markArr[1]), floatStrTrim(markArr[2])), - }) - addUserDataInGroup(newSvgGroup, {url: totalPlaceMarkHashUrlMap.get(hash)}) - placeMarkGroupMap.set(hash, newSvgGroup) - } - }) - - await Promise.all(promises2) - - if (!isDevMode()) { - // Remove unnecessary place mark meshes - const curPlaceMarkHashes = Array.from(placeMarkGroupMap.keys()) - const deletedPlaceMarkHashes = arrayDiff(curPlaceMarkHashes, totalPlaceMarkHashes) - deletedPlaceMarkHashes.forEach((hash) => { - placeMark.disposePlaceMark(placeMarkGroupMap.get(hash)) - }) - } - - resetPlaceMarkColors() - })() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [synchSidebar, placeMark]) - - - const createPlaceMark = ({context, oppositeObjects, postProcessor}) => { - debug().log('usePlaceMark#createPlaceMark') - const newPlaceMark = new PlaceMark({context, postProcessor}) - newPlaceMark.setObjects(oppositeObjects) - setPlaceMark(newPlaceMark) - } - - - const onSceneDoubleTap = useDoubleTap(async (event) => { - debug().log('usePlaceMark#onSceneDoubleTap') - if (!placeMark || !existPlaceMarkInFeature) { - return - } - const res = placeMark.onSceneDoubleClick(event) - - switch (event.button) { - case 0: // Main button (left button) - await savePlaceMark(res) - break - case 1: // Wheel button (middle button if present) - break - case 2: // Secondary button (right button) - break - case 3: // Fourth button (back button) - break - case 4: // Fifth button (forward button) - break - default: - break - } - }) - - - const onSceneSingleTap = async (event, callback) => { - debug().log('usePlaceMark#onSceneSingleTap') - if (!placeMark || !existPlaceMarkInFeature) { - return - } - const res = placeMark.onSceneClick(event) - - switch (event.button) { - case 0: // Main button (left button) - if (event.shiftKey) { - await savePlaceMark(res) - } else if (res.url) { - selectPlaceMark(res.url) - } - break - case 1: // Wheel button (middle button if present) - break - case 2: // Secondary button (right button) - break - case 3: // Fourth button (back button) - break - case 4: // Fifth button (forward button) - break - default: - break - } - - if (callback) { - callback(res) - } - } - - - const savePlaceMark = async ({point, promiseGroup}) => { - if (!existPlaceMarkInFeature) { - return - } - - if (point && promiseGroup) { - const svgGroup = await promiseGroup - const markArr = roundCoord(...point) - addHashParams(window.location, HASH_PREFIX_PLACE_MARK, markArr) - removeHashParams(window.location, HASH_PREFIX_CAMERA) - addUserDataInGroup(svgGroup, { - url: window.location.href, - }) - const hash = getHashParamsFromUrl(window.location.href, HASH_PREFIX_PLACE_MARK) - placeMarkGroupMap.set(hash, svgGroup) - setPlaceMarkStatus(svgGroup, true) - } - - deactivatePlaceMark() - if (!repository || !Array.isArray(notes)) { - return - } - const newNotes = [...notes] - const placeMarkNote = newNotes.find((note) => note.id === placeMarkId) - if (!placeMarkNote) { - return - } - const issueNumber = placeMarkNote.number - const newComment = { - body: `[placemark](${window.location.href})`, - } - await createComment(repository, issueNumber, newComment, accessToken) - toggleSynchSidebar() - } - - - const selectPlaceMark = (url) => { - if (!existPlaceMarkInFeature) { - return - } - assertDefined(url) - const hash = getHashParamsFromUrl(url, HASH_PREFIX_PLACE_MARK) - const svgGroup = placeMarkGroupMap.get(hash) - - if (svgGroup) { - setPlaceMarkStatus(svgGroup, true) - if (!isDevMode()) { - window.location.hash = `#${getAllHashParams(url)}` // Change location hash - } - } - } - - - const togglePlaceMarkActive = (id) => { - debug().log('usePlaceMark#togglePlaceMarkActive: id: ', id) - if (!existPlaceMarkInFeature) { - return - } - - if (placeMark) { - if (placeMarkId === id && placeMark.activated) { - deactivatePlaceMark() - } else { - activatePlaceMark() - } - } - - setPlaceMarkId(id) - } - - - const deactivatePlaceMark = () => { - if (!existPlaceMarkInFeature) { - return - } - placeMark.deactivate() - setPlaceMarkActivated(false) - } - - - const activatePlaceMark = () => { - if (!existPlaceMarkInFeature) { - return - } - placeMark.activate() - setPlaceMarkActivated(true) - } - - - 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 HASH_PREFIX_PLACE_MARK = 'm' -const placeMarkGroupMap = new Map() -let prevSynchSidebar diff --git a/src/net/github/Issues.fixture.js b/src/net/github/Issues.fixture.js index 5258873ad..4801593b3 100644 --- a/src/net/github/Issues.fixture.js +++ b/src/net/github/Issues.fixture.js @@ -1,6 +1,16 @@ const GITHUB_BASE_URL = process.env.GITHUB_BASE_URL_UNAUTHENTICATED export const sampleIssues = [ + { + id: 13, + title: 'placemark_test_1', + body: 'Placemark test1 note: [placemark](https://bldrs.ai/share/v/gh/nickcastel50/test-public/main/index.ifc#m:-18,20.289,-3.92,1,0,0)', + }, + { + id: 14, + title: 'placemark_test_2', + body: 'Placemark test2 note: [placemark](https://bldrs.ai/share/v/gh/nickcastel50/test-public/main/index.ifc#m:-47.076,18.655,0,0,0,1)', + }, { id: 123, title: 'issueTitle_1', diff --git a/src/store/NotesSlice.js b/src/store/NotesSlice.js index 7a5fa07d6..71a6e5a7f 100644 --- a/src/store/NotesSlice.js +++ b/src/store/NotesSlice.js @@ -24,8 +24,13 @@ export default function createNotesSlice(set, get) { placeMarkActivated: false, placeMarkId: null, selectedNoteId: null, + selectedCommentId: null, selectedNoteIndex: null, + selectedPlaceMarkId: null, synchSidebar: true, // To render again, not related to flag + placeMarkMode: false, + setSelectedPlaceMarkId: (_placeMarkId) => set(() => ({selectedPlaceMarkId: _placeMarkId})), + setPlaceMarkMode: (mode) => set(() => ({placeMarkMode: mode})), setComments: (comments) => set(() => ({comments: comments})), setCreatedNotes: (createdNotes) => set(() => ({createdNotes: createdNotes})), setDeletedNotes: (deletedNotes) => set(() => ({deletedNotes: deletedNotes})), @@ -37,11 +42,34 @@ export default function createNotesSlice(set, get) { setPlaceMarkId: (newPlaceMarkId) => set(() => ({placeMarkId: newPlaceMarkId})), setSelectedNote: (note) => set(() => ({selectedNote: note})), setSelectedNoteId: (noteId) => set(() => ({selectedNoteId: noteId})), + setSelectedCommentId: (commentId) => set(() => ({selectedCommentId: commentId})), setSelectedNoteIndex: (noteIndex) => set(() => ({selectedNoteIndex: noteIndex})), toggleAddComment: () => set((state) => ({addComment: !state.addComment})), toggleIsCreateNoteVisible: () => set((state) => ({isCreateNoteVisible: !state.isCreateNoteVisible})), toggleIsLoadingNotes: () => set((state) => ({isLoadingNotes: !state.isLoadingNotes})), toggleIsNotesVisible: () => set((state) => ({isNotesVisible: !state.isNotesVisible})), toggleSynchSidebar: () => set((state) => ({synchSidebar: !state.synchSidebar})), + body: '', + issueBody: '', + editModes: {}, // Keeps track of edit modes by NoteCard IDs + setEditMode: (id, mode) => + set((state) => ({ + editModes: {...state.editModes, [id]: mode}, + })), + editBodies: {}, // Track editBody for each NoteCard by id + setEditBody: (id, body) => + set((state) => ({ + editBodies: {...state.editBodies, [id]: body}, + })), + // Action to set the body + setBody: (newBody) => set({body: newBody}), + setIssueBody: (newIssueBody) => set({issueBody: newIssueBody}), + activeNoteCardId: null, + setActiveNoteCardId: (id) => set({activeNoteCardId: id}), + markers: [], + writeMarkers: (newMarkers) => set({markers: newMarkers}), // Set markers + clearMarkers: () => set({markers: []}), // Clear markers + selectedPlaceMarkInNoteId: null, + setSelectedPlaceMarkInNoteId: (_selectedPlaceMarkInNoteId) => set(() => ({selectedPlaceMarkInNoteId: _selectedPlaceMarkInNoteId})), } } diff --git a/src/utils/location.js b/src/utils/location.js index 56b77e1d3..a7a88432c 100644 --- a/src/utils/location.js +++ b/src/utils/location.js @@ -28,6 +28,58 @@ export function addHashListener(name, onHashCb) { hashListeners[name] = onHashCb } +/** + * Serialize the given paramObj and add it to the provided hash string. + * If no params are provided, adds the key with no value. + * + * @param {string} hashString The original hash string (including `#`). + * @param {string} name A unique name for the params. + * @param {Object} [params] The parameters to encode. If null or empty, adds key with no value. + * @param {boolean} includeNames Whether or not to include the parameter names in the encoding. + * @return {string} The updated hash string. + */ +export function setParamsToHash(hashString, name, params = {}, includeNames = false) { + if (hashString === '') { + hashString = '#' + } + + if (!hashString.startsWith('#')) { + throw new Error('Invalid hash string: must start with "#"') + } + + const FEATURE_SEP = ';' // Define the separator if not already defined + const existingHash = hashString.substring(1) // Remove the `#` prefix + const sets = existingHash.split(FEATURE_SEP) + + /** @type {Object} */ + const setMap = {} + + // Parse existing sets into a map + for (let i = 0; i < sets.length; i++) { + const set = sets[i] + if (!set) { +continue +} + + const [setName, ...setValueParts] = set.split(':') + const setValue = setValueParts.join(':') + setMap[setName] = setValue + } + + // Serialize the new params or set the key with no value + const encodedParams = params && Object.keys(params).length > 0 ? + getEncodedParam(params, includeNames) : + '' // Empty value for the key + setMap[name] = encodedParams + + // Construct the new hash + const newHash = Object.entries(setMap) + .map(([key, value]) => (value ? `${key}:${value}` : key)) // Include only the key if value is empty + .join(FEATURE_SEP) + + return `#${newHash}` +} + /** * Passhtru to addHashParams, with window.location @@ -214,7 +266,7 @@ export function getHashParamsFromHashStr(hashStr, name) { const prefix = `${name}:` for (let i = 0; i < sets.length; i++) { const set = sets[i] - if (set.startsWith(prefix)) { + if (set.startsWith('#') ? set.substring(1, set.length).startsWith(prefix) : set.startsWith(prefix)) { return set } } @@ -254,6 +306,130 @@ export function removeParams(name, paramKeys = []) { removeHashParams(window.location, name, paramKeys) } +/** + * Batches multiple hash updates into a single URL change. + * + * @param {Location} location The window.location object. + * @param {Array} hashUpdaters Array of hash update functions that modify the hash string. + */ +export function batchUpdateHash(location, hashUpdaters) { + const currentHash = location.hash + let newHash = currentHash + + // Apply all hash updates in sequence + hashUpdaters.forEach((updateFn) => { + newHash = updateFn(newHash) + }) + + // Apply the final hash string once + if (newHash !== currentHash) { + location.hash = newHash + } +} + + +/** + * Removes specific parameters from a hash string. + * + * @param {string} hashString The full hash string to process (including `#`). + * @param {string} name The prefix of the params to match. + * @param {Array} paramKeys Keys to remove from the hash params. If empty, removes the entire set. + * @return {string} The updated hash string. + */ +export function removeParamsFromHash(hashString, name, paramKeys = []) { + if (!hashString.startsWith('#') && hashString !== '') { + throw new Error('Invalid hash string: must start with "#"') + } + + // Remove `#` and split by FEATURE_SEP + const sets = hashString.substring(1).split(FEATURE_SEP) + const prefix = `${name}:` + const newSets = [] + + for (let i = 0; i < sets.length; i++) { + const set = sets[i] + + // Match the target prefix + if (set.startsWith(prefix)) { + if (!paramKeys.length) { + // If no specific keys, skip this entire set + continue + } + + // Handle `m`-style values or key-value pairs + const [key, value] = set.split(':') + if (value.includes(',')) { + // For coordinate-like values, retain the prefix and skip removal + newSets.push(set) + } else { + // For key-value style sets, filter out specified param keys + /** @type {Object} */ + const objectSet = getObjectParams(set) + paramKeys.forEach((paramKey) => { + delete objectSet[paramKey] + }) + const subSets = Object.entries(objectSet).map( + ([k, v]) => (v ? `${k}=${v}` : k), + ) + if (subSets.length > 0) { + newSets.push(`${key}:${subSets.join(',')}`) + } + } + } else { + // Add non-matching sets directly + newSets.push(set) + } + } + + const newHash = newSets.join(FEATURE_SEP) + return newHash ? `#${newHash}` : '' +} + + +/** + * Removes the given named hash param and constructs a modified hash string. + * + * @param {Location} location The location object to extract the hash from. + * @param {string} name The prefix of the params to fetch. + * @param {Array} paramKeys Keys to remove from the hash params. + * If empty, then remove all params under the given name. + * @return {string} The modified hash string. + */ +export function stripHashParams(location, name, paramKeys = []) { + assertObject(location) + const sets = location.hash.substring(1).split(FEATURE_SEP) + const prefix = `${name}:` + let newParamsEncoded = '' + + for (let i = 0; i < sets.length; i++) { + let set = sets[i] + + if (set.startsWith(prefix)) { + if (!paramKeys.length) { + continue // Skip this set entirely if no paramKeys and matches prefix + } + + // eslint-disable-next-line jsdoc/no-undefined-types + /** @type {Record} */ + const objectSet = getObjectParams(set) + paramKeys.forEach((paramKey) => { + delete objectSet[paramKey] // Remove the specified param keys + }) + /** @type {string[]} */ + const subSets = [] + Object.entries(objectSet).forEach(([key, value]) => { + subSets.push(value ? `${key}=${value}` : key) + }) + set = `${prefix}${subSets.join(',')}` + } + + const separator = newParamsEncoded.length === 0 ? '' : FEATURE_SEP + newParamsEncoded += separator + set + } + + return newParamsEncoded ? `#${newParamsEncoded}` : '' +} + /** * Removes the given named hash param diff --git a/tools/esbuild/proxy.js b/tools/esbuild/proxy.js index a99541da2..85bdd71c2 100644 --- a/tools/esbuild/proxy.js +++ b/tools/esbuild/proxy.js @@ -95,13 +95,18 @@ const serveNotFound = (res) => { * @return {string} The rewritten URL */ function rewriteUrl(url) { - // If the URL is for a .wasm file and starts with '/share/v/p/', rewrite it - if (url.endsWith('.wasm') && url.startsWith('/share/v/p/')) { - return url.replace('/share/v/p/', '/') + // Regular expression to match any URL that ends with .wasm + const regex = /^.*\.wasm$/ + + // If the URL matches the regex, rewrite it + if (regex.test(url)) { + return '/static/js/ConwayGeomWasmWeb.wasm' } + return url } + /** * Get the Content-Type based on file extension. *