diff --git a/client/src/DocumentViewer.js b/client/src/DocumentViewer.js index 829ae830..f10fa160 100644 --- a/client/src/DocumentViewer.js +++ b/client/src/DocumentViewer.js @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; +import { withRouter } from 'react-router'; import { DragSource, DropTarget @@ -12,7 +13,7 @@ import Close from 'material-ui/svg-icons/navigation/close'; import Visibility from 'material-ui/svg-icons/action/visibility'; import VisibilityOff from 'material-ui/svg-icons/action/visibility-off'; import Description from 'material-ui/svg-icons/action/description'; -import { grey100, grey800, grey900 } from 'material-ui/styles/colors'; +import { grey100, grey200, grey300, grey800, grey900, white } from 'material-ui/styles/colors'; import { updateDocument, closeDocument, moveDocumentWindow, layoutOptions } from './modules/documentGrid'; import { toggleCanvasHighlights } from './modules/canvasEditor'; import { toggleTextHighlights } from './modules/textEditor'; @@ -20,7 +21,8 @@ import { closeDocumentTargets } from './modules/annotationViewer'; import TextResource from './TextResource'; import CanvasResource from './CanvasResource'; import DocumentStatusBar from './DocumentStatusBar'; -import { Popover } from 'material-ui'; +import { Popover, RaisedButton } from 'material-ui'; +import { BoxArrowUp, Check2 } from 'react-bootstrap-icons'; const DocumentInner = function(props) { switch (props.document_kind) { @@ -90,6 +92,10 @@ class DocumentViewer extends Component { doneSaving: true, cornerIconTooltipOpen: false, cornerIconTooltipAnchor: null, + sharePanelOpen: false, + sharePanelAnchor: null, + documentURL: '', + hasCopiedURL: false, } } @@ -103,6 +109,7 @@ class DocumentViewer extends Component { componentDidMount() { this.props.connectDragPreview(new Image()); + this.getDocumentURL(this.props.document_id); } isEditable = () => { @@ -174,6 +181,50 @@ class DocumentViewer extends Component { }); } + onShareOpen (e) { + e.persist(); + const sharePanelAnchor = e.currentTarget; + e.preventDefault(); + this.setState((prevState) => { + return { + ...prevState, + sharePanelOpen: true, + sharePanelAnchor, + } + }); + } + + onShareClose () { + this.setState((prevState) => { + return { + ...prevState, + sharePanelOpen: false, + hasCopiedURL: false, + } + }); + } + + getDocumentURL(docId) { + const loc = window.location.href.replace(window.location.search, ""); + this.setState((prevState) => ({ + ...prevState, + documentURL: `${loc}?document=${docId}` + })); + } + + copyDocumentURL(e) { + if (e.currentTarget.nodeName === "INPUT") { + e.currentTarget.select(); + } else { + e.currentTarget.parentNode.parentNode.querySelector("#document-link").select(); + } + navigator.clipboard.writeText(this.state.documentURL); + this.setState((prevState) => ({ + ...prevState, + hasCopiedURL: true, + })); + } + renderTitleBar() { const iconStyle = { padding: '0', @@ -218,8 +269,60 @@ class DocumentViewer extends Component { onChange={this.onChangeTitle} disabled={!this.isEditable()} /> + + + + + + + : null} + label={this.state.hasCopiedURL ? "Copied" : "Copy link"} + style={{marginRight: '10px'}} + onClick={this.copyDocumentURL.bind(this)} + backgroundColor={this.state.hasCopiedURL ? grey300 : white} + /> + + { !this.isEditable() && - + { highlightsHidden ? bindActionCreators({ export default connect( mapStateToProps, mapDispatchToProps -)(DocumentViewer); +)(withRouter((props) => )); + diff --git a/client/src/Project.js b/client/src/Project.js index 550aef81..8aa3d933 100644 --- a/client/src/Project.js +++ b/client/src/Project.js @@ -5,7 +5,7 @@ import HTML5Backend from 'react-dnd-html5-backend'; import { DragDropContext } from 'react-dnd'; import { loadProject, updateProject, showSettings, hideSettings, checkInAll } from './modules/project'; import { selectTarget, closeTarget, closeTargetRollover, promoteTarget } from './modules/annotationViewer'; -import { closeDeleteDialog, confirmDeleteDialog, layoutOptions, updateSnackBar, fetchLock } from './modules/documentGrid'; +import { closeDeleteDialog, confirmDeleteDialog, layoutOptions, openDocument, openInitialDocs, updateSnackBar, fetchLock } from './modules/documentGrid'; import { selectHighlight } from './modules/textEditor'; import Dialog from 'material-ui/Dialog'; import Snackbar from 'material-ui/Snackbar'; @@ -118,6 +118,11 @@ class Project extends Component { window.hideRollover = this.hideRollover.bind(this); if (this.props.match.params.slug !== 'new') { this.props.loadProject(this.props.match.params.slug, this.props.projectTitle) + // open documents included in query params + const queryParams = new URLSearchParams(this.props.location.search); + if (queryParams) { + this.props.openInitialDocs(queryParams.getAll("document")); + } } } @@ -128,6 +133,17 @@ class Project extends Component { this.props.fetchLock(id); }); } + if (prevProps.openDocuments !== this.props.openDocuments && !this.props.loadingInitialDocs) { + // update query params when opening or closing a document + const queryParams = new URLSearchParams(); + this.props.openDocuments.forEach((doc) => { + queryParams.append("document", doc.id.toString()); + }); + this.props.history.replace({ + pathname: this.props.location.pathname, + search: `?${queryParams.toString()}`, + }); + } } renderDeleteDialog() { @@ -325,6 +341,7 @@ const mapStateToProps = state => ({ deleteDialogTitle: state.documentGrid.deleteDialogTitle, deleteDialogBody: state.documentGrid.deleteDialogBody, deleteDialogSubmit: state.documentGrid.deleteDialogSubmit, + loadingInitialDocs: state.documentGrid.loadingInitialDocs, snackBarOpen: state.documentGrid.snackBarOpen, snackBarMessage: state.documentGrid.snackBarMessage, currentLayout: layoutOptions[state.documentGrid.currentLayout], @@ -336,20 +353,22 @@ const mapStateToProps = state => ({ }); const mapDispatchToProps = dispatch => bindActionCreators({ - loadProject, - updateProject, - selectTarget, + checkInAll, + closeDeleteDialog, closeTarget, closeTargetRollover, - promoteTarget, - closeDeleteDialog, confirmDeleteDialog, - showSettings, - checkInAll, - updateSnackBar, + fetchLock, hideSettings, + loadProject, + openDocument, + openInitialDocs, + promoteTarget, selectHighlight, - fetchLock, + selectTarget, + showSettings, + updateProject, + updateSnackBar, }, dispatch); export default connect( diff --git a/client/src/modules/documentGrid.js b/client/src/modules/documentGrid.js index d0278487..fe49f6d5 100644 --- a/client/src/modules/documentGrid.js +++ b/client/src/modules/documentGrid.js @@ -23,7 +23,7 @@ import { selectHighlight, setHighlightSelectMode } from './textEditor'; -import { deleteFolder } from './folders'; +import { deleteFolder, openFolder } from './folders'; import { setAddTileSourceMode, UPLOAD_SOURCE_TYPE, @@ -87,6 +87,9 @@ export const GET_CURRENT_DOC_CONTENT_ERRORED = 'document_grid/GET_CURRENT_DOC_CO export const FETCH_LOCK_SUCCESS = 'document_grid/FETCH_LOCK_SUCCESS'; export const FETCH_LOCK_ERRORED = 'document_grid/FETCH_LOCK_ERRORED'; export const FROM_IMAGE_SUCCESS = 'document_grid/FROM_IMAGE_SUCCESS'; +export const OPEN_INITIAL_DOCS_STARTED = 'document_grid/OPEN_INITIAL_DOCS_STARTED'; +export const OPEN_INITIAL_DOCS_SUCCESS = 'document_grid/OPEN_INITIAL_DOCS_SUCCESS'; +export const OPEN_INITIAL_DOCS_ERRORED = 'document_grid/OPEN_INITIAL_DOCS_ERRORED'; export const layoutOptions = [ @@ -112,7 +115,8 @@ const initialState = { deleteDialogKind: null, snackBarOpen: false, snackBarMessage: null, - currentLayout: 2 + currentLayout: 2, + loadingInitialDocs: false, }; export default function(state = initialState, action) { @@ -410,6 +414,20 @@ export default function(state = initialState, action) { openDocuments: openDocumentsFetchUpdated }; + case OPEN_INITIAL_DOCS_STARTED: + return { + ...state, + loadingInitialDocs: true, + }; + + case OPEN_INITIAL_DOCS_ERRORED: + case OPEN_INITIAL_DOCS_SUCCESS: + return { + ...state, + loadingInitialDocs: false, + loading: false, + }; + default: return state; } @@ -1797,4 +1815,105 @@ export function createBatchImages ({ console.error(error); } }; -} \ No newline at end of file +} + +export function openInitialDocs(docIds) { + return async function(dispatch, getState) { + if (docIds.length > 0) { + dispatch({ + type: OPEN_INITIAL_DOCS_STARTED, + }); + await Promise.all(docIds.map(async (documentId, idx) => { + return fetch(`/documents/${documentId}`, { + headers: { + 'access-token': localStorage.getItem('access-token'), + 'token-type': localStorage.getItem('token-type'), + 'client': localStorage.getItem('client'), + 'expiry': localStorage.getItem('expiry'), + 'uid': localStorage.getItem('uid') + } + }) + .then(response => { + if (!response.ok) { + throw Error(response.statusText); + } + return response; + }) + .then(response => response.json()) + .then(document => { + return document; + }) + .then(async document => { + dispatch({ + type: OPEN_DOCUMENT_SUCCESS, + document, + firstTarget: null, + documentPosition: idx, + }); + if (document.parent_type === "DocumentFolder") { + // open parent folder if not open + const { openFolderContents } = getState().folders; + if (!Object.hasOwn(openFolderContents, document.parent_id)) { + dispatch(openFolder(document.parent_id)); + } + } else if (document.parent_type === "Document" && !docIds.includes(document.parent_id.toString())) { + // if document is an annotation (parent is a document), + // open parent document at position 0 + await fetch(`/documents/${document.parent_id}`, { + headers: { + 'access-token': localStorage.getItem('access-token'), + 'token-type': localStorage.getItem('token-type'), + 'client': localStorage.getItem('client'), + 'expiry': localStorage.getItem('expiry'), + 'uid': localStorage.getItem('uid') + } + }) + .then(response => { + if (!response.ok) { + throw Error(response.statusText); + } + return response; + }) + .then(response => response.json()) + .then(parentDoc => { + return parentDoc; + }) + .then(parentDoc => { + const parentLink = document.links_to.find((link) => link.document_id === parentDoc.id); + const firstTarget = parentLink && parentLink.highlight_uid; + dispatch({ + type: OPEN_DOCUMENT_SUCCESS, + document: parentDoc, + firstTarget, + documentPosition: idx - 1 >= 0 ? idx - 1 : 0, + }); + return Promise.resolve(); + }) + .catch(() => { + dispatch({ + type: OPEN_DOCUMENT_ERRORED + }); + dispatch({ + type: OPEN_INITIAL_DOCS_ERRORED, + }); + return Promise.reject(); + }); + } + return Promise.resolve(); + }) + .catch(() => { + dispatch({ + type: OPEN_DOCUMENT_ERRORED + }); + dispatch({ + type: OPEN_INITIAL_DOCS_ERRORED, + }); + return Promise.reject(); + }); + })); + dispatch({ + type: OPEN_INITIAL_DOCS_SUCCESS, + }); + } + } +} diff --git a/client/src/modules/folders.js b/client/src/modules/folders.js index 719ded0c..9c536a24 100644 --- a/client/src/modules/folders.js +++ b/client/src/modules/folders.js @@ -110,7 +110,7 @@ export function createFolder(parentId, parentType, title = 'New Folder') { } export function openFolder(id) { - return function(dispatch) { + return function(dispatch, getState) { dispatch({ type: FOLDER_OPENED, id @@ -132,11 +132,20 @@ export function openFolder(id) { return response; }) .then(response => response.json()) - .then(folder => dispatch({ - type: OPEN_SUCCESS, - id, - contentsChildren: folder.contents_children - })) + .then(folder => { + dispatch({ + type: OPEN_SUCCESS, + id, + contentsChildren: folder.contents_children + }); + if (folder.parent_type === "DocumentFolder") { + // open all parent folders up to root if not open + const { openFolderContents } = getState().folders; + if (!Object.hasOwn(openFolderContents, folder.parent_id)) { + dispatch(openFolder(folder.parent_id)); + } + } + }) .catch(() => dispatch({ type: OPEN_ERRORED }));