From a897bb48064a4348e755ff1a3310fdb75338d5a7 Mon Sep 17 00:00:00 2001 From: Jake Low Date: Mon, 12 Aug 2024 16:44:13 -0700 Subject: [PATCH 01/10] Refactor Rapid integration to load the editor in an iframe --- package.json | 7 +- public/rapid-editor.html | 87 ++ src/components/HOCs/WithEditor/WithEditor.js | 2 +- .../__snapshots__/WithEditor.test.js.snap | 2 +- .../ActiveTaskControls/ActiveTaskControls.js | 3 +- .../TaskMapWidget/RapidEditor/RapidEditor.js | 315 +---- .../Widgets/TaskMapWidget/TaskMapWidget.js | 7 +- src/hooks/UseHash.js | 34 + src/services/RapidEditor/RapidEditor.js | 5 +- yarn.lock | 1101 +---------------- 10 files changed, 203 insertions(+), 1360 deletions(-) create mode 100644 public/rapid-editor.html create mode 100644 src/hooks/UseHash.js diff --git a/package.json b/package.json index fea160229..99b81b282 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "@nivo/line": "^0.79.1", "@nivo/radar": "^0.79.1", "@popperjs/core": "^2.8.4", - "@rapideditor/rapid": "^2.1.1", "@react-leaflet/core": "^2.1.0", "@rjsf/core": "^4.2.3", "@turf/bbox": "^6.0.1", @@ -137,11 +136,9 @@ "update-layers": "node scripts/update_layers.js", "update-layers-prod": "NODE_ENV=production node scripts/update_layers.js", "update-presets": "node scripts/update_presets.js", - "copy-static": "bash -c \"mkdir -p public/static/id; mkdir -p public/static/rapid; if ! (test -a public/static/rapid/rapid.js); then cp -R node_modules/@rapideditor/rapid/dist/* public/static/rapid; fi\"", - "update-static": "bash -c \"mkdir -p public/static/id; mkdir -p public/static/rapid; cp -R node_modules/@rapideditor/rapid/dist/* public/static/rapid;\"", "start-js": "react-scripts --max_old_space_size=4096 start", - "start": "npm-run-all -p copy-static update-layers watch-postcss start-js", - "build": "yarn update-static && yarn run build-intl && yarn run update-layers-prod && yarn run build-postcss && react-scripts --max_old_space_size=4096 build", + "start": "npm-run-all -p update-layers watch-postcss start-js", + "build": "yarn run build-intl && yarn run update-layers-prod && yarn run build-postcss && react-scripts --max_old_space_size=4096 build", "test": "react-scripts test --transformIgnorePatterns \"node_modules/(?!react-leaflet)/\" --resetMocks=false --detectOpenHandles", "test:cov": "react-scripts test --transformIgnorePatterns \"node_modules/(?!react-leaflet)/\" --coverage --resetMocks=false", "test:cov-full": "react-scripts test --transformIgnorePatterns \"node_modules/(?!react-leaflet)/\" --coverage --watchAll --resetMocks=false", diff --git a/public/rapid-editor.html b/public/rapid-editor.html new file mode 100644 index 000000000..bea59eb21 --- /dev/null +++ b/public/rapid-editor.html @@ -0,0 +1,87 @@ + + + + + + Rapid + + + + + + + + + +
+ + + + diff --git a/src/components/HOCs/WithEditor/WithEditor.js b/src/components/HOCs/WithEditor/WithEditor.js index 9ce76e389..7f068ffce 100644 --- a/src/components/HOCs/WithEditor/WithEditor.js +++ b/src/components/HOCs/WithEditor/WithEditor.js @@ -26,7 +26,7 @@ export const mapStateToProps = state => { return ({ editor: state.openEditor, configuredEditor: _get(userEntity, 'settings.defaultEditor', DEFAULT_EDITOR), - rapidContext: _get(state, 'rapidEditor.context'), + rapidEditorState: _get(state, 'rapidEditor'), }) } diff --git a/src/components/HOCs/WithEditor/__snapshots__/WithEditor.test.js.snap b/src/components/HOCs/WithEditor/__snapshots__/WithEditor.test.js.snap index 56811127e..5fb9090fb 100644 --- a/src/components/HOCs/WithEditor/__snapshots__/WithEditor.test.js.snap +++ b/src/components/HOCs/WithEditor/__snapshots__/WithEditor.test.js.snap @@ -4,6 +4,6 @@ exports[`mapStateToProps provides an editor prop with the current open editor 1` Object { "configuredEditor": 0, "editor": 1, - "rapidContext": undefined, + "rapidEditorState": undefined, } `; diff --git a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.js b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.js index 8307bde06..292b27f2d 100644 --- a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.js +++ b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.js @@ -170,11 +170,10 @@ export class ActiveTaskControls extends Component { } initiateCompletion = (taskStatus, submitRevision) => { - const hasUnsavedRapidChanges = this.props.rapidContext?.systems.editor.hasChanges() const intl = this.props.intl const message = intl.formatMessage(messages.rapidDiscardUnsavedChanges) - if (!hasUnsavedRapidChanges || window.confirm(message)) { + if (!this.props.rapidEditorState.hasUnsavedChanges || window.confirm(message)) { this.setState({ confirmingTask: this.props.task, osmComment: `${this.props.task.parent.checkinComment}${constructChangesetUrl(this.props.task)}`, diff --git a/src/components/Widgets/TaskMapWidget/RapidEditor/RapidEditor.js b/src/components/Widgets/TaskMapWidget/RapidEditor/RapidEditor.js index f43453823..2959083dc 100644 --- a/src/components/Widgets/TaskMapWidget/RapidEditor/RapidEditor.js +++ b/src/components/Widgets/TaskMapWidget/RapidEditor/RapidEditor.js @@ -1,285 +1,84 @@ -//credit https://github.com/hotosm/tasking-manager/blob/develop/frontend/src/components/rapidEditor.js for implementation guidance +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import _get from 'lodash/get'; -import { useEffect, useState } from 'react' -import _get from 'lodash/get' import { constructRapidURI } from '../../../../services/Editor/Editor.js'; import { replacePropertyTags } from '../../../../hooks/UsePropertyReplacement/UsePropertyReplacement.js'; import AsMappableTask from '../../../../interactions/Task/AsMappableTask.js'; import { DEFAULT_ZOOM } from '../../../../services/Challenge/ChallengeZoom/ChallengeZoom.js'; -import { useDispatch, useSelector } from 'react-redux'; -import rapidPackage from '@rapideditor/rapid/package.json'; import WithSearch from '../../../HOCs/WithSearch/WithSearch.js'; - -const { version: rapidVersion, name: rapidName } = rapidPackage; -const baseCdnUrl = `https://cdn.jsdelivr.net/npm/${rapidName}@~${rapidVersion}/dist/`; - -/** - * Check if two URL search parameters are semantically equal - * @param {URLSearchParams} first - * @param {URLSearchParams} second - * @return {boolean} true if they are semantically equal - */ -function equalsUrlParameters(first, second) { - if (first.size === second.size) { - for (const [key, value] of first) { - if (!second.has(key) || second.get(key) !== value) { - return false; - } - } - return true; - } - return false; -} +import useHash from '../../../../hooks/UseHash.js'; +import { SET_RAPIDEDITOR } from '../../../../services/RapidEditor/RapidEditor.js'; /** - * Update the URL (this also fires a hashchange event) - * @param {URLSearchParams} hashParams the URL hash parameters - */ -function updateUrl(hashParams) { - const oldUrl = window.location.href; - const newUrl = window.location.pathname + window.location.search + '#' + hashParams.toString(); - window.history.pushState(null, '', newUrl); - window.dispatchEvent( - new HashChangeEvent('hashchange', { - newUrl: newUrl, - oldUrl: oldUrl, - }), - ); -} - -/** - * Generate the starting hash for the project - * @param {string | undefined} comment The comment to use - * @param {Array. | undefined} presets The presets - * @param {string | undefined} gpxUrl The task boundaries - * @param {boolean | undefined} powerUser if the user should be shown advanced options - * @param {string | undefined} imagery The imagery to use for the task - * @return {module:url.URLSearchParams | boolean} the new URL search params or {@code false} if no parameters changed + * Generate the initial URL hash for the Rapid editor. */ function generateStartingHash({ mapBounds, task, comment }) { - let replacedComment = comment - const asMappableTask = task ? AsMappableTask(task) : null + let replacedComment = comment; + const asMappableTask = task ? AsMappableTask(task) : null; if (asMappableTask) { - const taskFeatureProperties = asMappableTask.allFeatureProperties() - if(taskFeatureProperties && Object.keys(taskFeatureProperties).length) { - replacedComment = replacePropertyTags(comment, taskFeatureProperties, false) + const taskFeatureProperties = asMappableTask.allFeatureProperties(); + if (taskFeatureProperties && Object.keys(taskFeatureProperties).length) { + replacedComment = replacePropertyTags(comment, taskFeatureProperties, false); } if (!mapBounds) { - mapBounds = { - ...asMappableTask.calculateCenterPoint() - } + mapBounds = asMappableTask.calculateCenterPoint(); } if (!mapBounds.zoom) { - mapBounds.zoom = _get(task, "parent.defaultZoom", DEFAULT_ZOOM) + mapBounds.zoom = _get(task, 'parent.defaultZoom', DEFAULT_ZOOM); } } - const rapidUrl = constructRapidURI(task, mapBounds, {}, replacedComment) - const rapidParams = rapidUrl.split('#')[1] - - if (equalsUrlParameters(new URLSearchParams(rapidParams), new URLSearchParams(window.location.hash.substring(1)))) { - return false; - } - - return rapidParams + const rapidUrl = constructRapidURI(task, mapBounds, {}, replacedComment); + const rapidParams = new URL(rapidUrl).hash; + return rapidParams; } -/** - * Resize rapid - * @param {Context} rapidContext The rapid context to resize - * @type {import('@rapideditor/rapid').Context} Context - */ -function resizeRapid(rapidContext) { - // Get rid of black bars when toggling the TM sidebar - const uiSystem = rapidContext?.systems?.ui; - if (uiSystem?.started) { - uiSystem.resize(); - } -} - -/** - * Check if there are changes - * @param changes The changes to check - * @returns {boolean} {@code true} if there are changes - */ -function thereAreChanges(changes) { - return changes.modified.length || changes.created.length || changes.deleted.length; -} - -/** - * Update the disable state for the sidebar map actions - * @param {function(boolean)} setDisable - * @param {EditSystem} editSystem The edit system - * @type {import('@rapideditor/rapid/modules').EditSystem} EditSystem - */ -function updateDisableState(setDisable, editSystem) { - if (thereAreChanges(editSystem.changes())) { - setDisable(true); - } else { - setDisable(false); - } -} - -const RapidEditor = ({ - setDisable, - locale, - token, - task, - mapBounds, - comment, - showSidebar, - presets, - gpxUrl, - powerUser, - imagery -}) => { - const dispatch = useDispatch(); - const [rapidLoaded, setRapidLoaded] = useState(window.Rapid !== undefined); - const { context, dom } = useSelector((state) => state.rapidEditor); - const windowInit = typeof window !== 'undefined'; - - // This significantly reduces build time _and_ means different TM instances can share the same download of Rapid. - // Unfortunately, Rapid doesn't use a public CDN itself, so we cannot reuse that. - useEffect(() => { - if (!rapidLoaded && !context) { - // Add the style element - const style = document.createElement('link'); - style.setAttribute('type', 'text/css'); - style.setAttribute('rel', 'stylesheet'); - style.setAttribute('href', baseCdnUrl + 'rapid.css'); - document.head.appendChild(style); - // Now add the editor - const script = document.createElement('script'); - script.src = baseCdnUrl + 'rapid.js'; - script.async = true; - script.onload = () => setRapidLoaded(true); - document.body.appendChild(script); - } else if (context && !rapidLoaded) { - setRapidLoaded(true); - } - }, [rapidLoaded, setRapidLoaded, context]); - - useEffect(() => { - return () => { - dispatch({ type: 'SET_VISIBILITY', isVisible: true }); - }; - }); - - useEffect(() => { - if (windowInit && context === null && rapidLoaded) { - /* This is used to avoid needing to re-initialize Rapid on every page load -- this can lead to jerky movements in the UI */ - const dom = document.createElement('div'); - dom.className = 'w-100 vh-minus-69-ns'; - dom.style = { height: "100%" } - // we need to keep Rapid context on redux store because Rapid works better if - // the context is not restarted while running in the same browser session - // Unfortunately, we need to recreate the context every time we recreate the rapid-container dom node. - const context = new window.Rapid.Context(); - context.embed(true); - context.containerNode = dom; - context.assetPath = baseCdnUrl; - context.apiConnections = [ - { - url: process.env.REACT_APP_OSM_SERVER, - apiUrl: 'https://api.openstreetmap.org', - access_token: token, - }, - ]; - dispatch({ type: 'SET_RAPIDEDITOR', context: { context, dom } }); - } - }, [windowInit, rapidLoaded, context, dispatch]); - - useEffect(() => { - if (context) { - // setup the context - context.locale = locale; - } - }, [context, locale]); - - // This ensures that Rapid has the correct map size - useEffect(() => { - // This might be a _slight_ efficiency improvement by making certain that Rapid isn't painting unneeded items - resizeRapid(context); - // This is the only bit that is *really* needed -- it prevents black bars when hiding the sidebar. - return () => resizeRapid(context); - }, [showSidebar, context]); - - useEffect(() => { - if (task?.id) { - const newParams = generateStartingHash({ mapBounds, task, comment }); - if (newParams) { - updateUrl(newParams); - } - } - }, [comment, presets, gpxUrl, powerUser, imagery]); +const RapidEditor = ({ token, task, mapBounds, comment }) => { + let dispatch = useDispatch(); + let initialHash = generateStartingHash({ task, mapBounds, comment }); + let [, setHash] = useHash(); useEffect(() => { - if (task?.id) { - const newParams = generateStartingHash({ task, comment }); - if (newParams) { - updateUrl(newParams); - } - } - }, [task?.id]); - - useEffect(() => { - const containerRoot = document.getElementById('rapid-container-root'); - const editListener = () => updateDisableState(setDisable, context.systems.edits); - if (context && dom) { - containerRoot.appendChild(dom); - // init the ui or restart if it was loaded previously - let promise; - if (context?.systems?.ui !== undefined) { - // Currently commented out in Rapid source code (2023-07-20) - // RapidContext.systems.ui.restart(); - resizeRapid(context); - promise = Promise.resolve(); - } else { - promise = context.initAsync(); - } - - /* Perform tasks after Rapid has started up */ - promise.then(() => { - /* Keep track of edits */ - const editSystem = context.systems.editor; - - editSystem.on('change', editListener); - editSystem.on('reset', editListener); - }); - } - return () => { - if (containerRoot?.childNodes && dom in containerRoot.childNodes) { - document.getElementById('rapid-container-root')?.removeChild(dom); - } - if (context?.systems?.edits) { - const editSystem = context.systems.edits; - editSystem.off('change', editListener); - editSystem.off('reset', editListener); - } - }; - }, [dom, context, setDisable]); - - useEffect(() => { - if (context?.save) { - return () => context.save(); - } - }, [context]); - - useEffect(() => { - if (context && token) { - context.preauth = { - url: process.env.REACT_APP_OSM_SERVER, - apiUrl: 'https://api.openstreetmap.org', - access_token: token, - }; - context.apiConnections = [context.preauth]; - } - }, [context, token]); - - return
; -} + // when this component unmounts, reset the rapid editor state fields in our Redux store + return () => dispatch({ type: SET_RAPIDEDITOR, context: { isRunning: false, hasUnsavedChanges: false } }); + }, []); + + return ( +