From b50ff3d5892a7b32b73708cdc7c4edc08f290be4 Mon Sep 17 00:00:00 2001 From: Nick Laiacona Date: Wed, 21 Dec 2022 08:54:01 -0500 Subject: [PATCH 1/8] start 1.1.5 dev --- package.json | 6 +++--- public/main-process/config/dist-config.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index afcc6409..6ca312db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "faircopy", - "version": "1.1.4", + "version": "1.1.5-dev.1", "description": "A word processor for the humanities scholar.", "main": "public/electron.js", "private": true, @@ -123,8 +123,8 @@ "publish": { "provider": "keygen", "account": "8a8d3d6a-ab09-4f51-aea5-090bfd025dd8", - "product": "1e330e29-f0b4-4942-b813-0c78614e3abb", - "channel": "stable" + "product": "b2bfc67b-26bf-4407-b3d9-d7ad94d7f225", + "channel": "dev" } }, "postinstall": "electron-builder install-app-deps", diff --git a/public/main-process/config/dist-config.json b/public/main-process/config/dist-config.json index 8df234ac..04b67e22 100644 --- a/public/main-process/config/dist-config.json +++ b/public/main-process/config/dist-config.json @@ -1,5 +1,5 @@ { - "devMode": false, + "devMode": true, "devURL": "https://faircopy-activate-2-staging.herokuapp.com", "prodURL": "https://faircopyeditor.com" } \ No newline at end of file From 12bac85dbbc827c1bee98a05353a105bcc267788 Mon Sep 17 00:00:00 2001 From: Nick Laiacona Date: Thu, 19 Jan 2023 09:15:32 -0500 Subject: [PATCH 2/8] close child resources of deleted parent --- src/components/main-window/MainWindow.js | 3 ++- src/components/main-window/dialogs/AlertDialog.js | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/main-window/MainWindow.js b/src/components/main-window/MainWindow.js index 5a48c679..461eaed7 100644 --- a/src/components/main-window/MainWindow.js +++ b/src/components/main-window/MainWindow.js @@ -556,7 +556,8 @@ export default class MainWindow extends Component { case 'delete': { const { fairCopyProject } = this.props - const alertOptions = { resourceIDs } + const { openResources } = this.state + const alertOptions = { resourceIDs, openResources } if( fairCopyProject.areEditable( resourceEntries ) ) { this.setState({ ...nextState, alertDialogMode: 'confirmDelete', alertOptions, ...closePopUpState }) } else { diff --git a/src/components/main-window/dialogs/AlertDialog.js b/src/components/main-window/dialogs/AlertDialog.js index b4f8f96e..55ddc27f 100644 --- a/src/components/main-window/dialogs/AlertDialog.js +++ b/src/components/main-window/dialogs/AlertDialog.js @@ -70,10 +70,19 @@ export default class AlertDialog extends Component { renderConfirmDelete() { const { alertOptions, onCloseAlert, closeResources, fairCopyProject } = this.props - const { resourceIDs } = alertOptions + const { resourceIDs, openResources } = alertOptions const onDelete = () => { - closeResources(resourceIDs, false, false ) + const closingResourceIDs = [ ...resourceIDs ] + + // we need to see if any of the open resources have a doomed resource as a parent and add them to close list + for( const openResource of Object.values(openResources) ) { + if( resourceIDs.includes( openResource.parentEntry.id ) ) { + closingResourceIDs.push( openResource.resourceID ) + } + } + + closeResources(closingResourceIDs, false, false ) fairCopyProject.removeResources(resourceIDs) } From 05c08b5e4455360f92edaa9003a7c4638bf09ee3 Mon Sep 17 00:00:00 2001 From: Nick Laiacona Date: Tue, 24 Jan 2023 07:57:19 -0500 Subject: [PATCH 3/8] Improve user experience around logging in/out (#495) * update env * logout when unauthorized * handle logout better * refactor to correct method signature * allow user to reopen the project * can't check in/out config when offline * make enter work in log in dialog * handle offline parent resource --- .vscode/launch.json | 2 +- public/main-process/FairCopyApplication.js | 5 + public/main-process/FairCopySession.js | 5 + public/main-process/RemoteProject.js | 6 +- public/main-process/WorkerWindow.js | 2 +- src/components/main-window/MainWindow.js | 13 +-- .../main-window/dialogs/LoginDialog.js | 7 +- .../resource-browser/ResourceBrowser.js | 17 +-- .../ProjectSettingsWindow.js | 3 +- src/model/cloud-api/config.js | 16 +-- src/model/cloud-api/error-handler.js | 17 +-- src/model/cloud-api/id-map.js | 14 +-- src/model/cloud-api/projects.js | 4 +- src/model/cloud-api/resource-management.js | 11 +- src/model/cloud-api/resources.js | 35 ++---- src/workers/project-archive-worker.js | 12 +-- src/workers/remote-project-worker.js | 101 ++++++++++-------- 17 files changed, 142 insertions(+), 128 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 5292868e..31f31fa1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -40,7 +40,7 @@ "windows": { "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" }, - "env": { "FAIRCOPY_DEBUG_MODE": "true", "FAIRCOPY_DEV_VERSION": "1.1.3" }, + "env": { "FAIRCOPY_DEBUG_MODE": "true", "FAIRCOPY_DEV_VERSION": "1.1.5" }, "program": "${workspaceRoot}/public/electron.js", "protocol": "inspector", } diff --git a/public/main-process/FairCopyApplication.js b/public/main-process/FairCopyApplication.js index 227b70fd..408cea02 100644 --- a/public/main-process/FairCopyApplication.js +++ b/public/main-process/FairCopyApplication.js @@ -65,6 +65,11 @@ class FairCopyApplication { this.exitApp() } }) + + ipcMain.on('reopenProject', (event) => { + this.fairCopySession.reopenProject() + }) + ipcMain.on('addResource', (event, resourceEntry, resourceData, resourceMap) => { this.fairCopySession.addResource(resourceEntry,resourceData,resourceMap) }) ipcMain.on('removeResources', (event, resourceIDs) => { diff --git a/public/main-process/FairCopySession.js b/public/main-process/FairCopySession.js index a8124c49..ca3e1139 100644 --- a/public/main-process/FairCopySession.js +++ b/public/main-process/FairCopySession.js @@ -61,6 +61,11 @@ class FairCopySession { if( this.remoteProject ) this.remoteProject.close() } + reopenProject() { + this.remoteProject.open() + this.requestResourceView() + } + openImageResource(url) { this.projectStore.openImageResource(url) } diff --git a/public/main-process/RemoteProject.js b/public/main-process/RemoteProject.js index 69bd8a11..1a2af661 100644 --- a/public/main-process/RemoteProject.js +++ b/public/main-process/RemoteProject.js @@ -8,7 +8,7 @@ class RemoteProject { const {baseDir} = fairCopyApplication this.fairCopySession = fairCopySession this.initRemoteProjectWorker( baseDir, fairCopyApplication.isDebugMode(), userID, serverURL, projectID ).then(() => { - this.remoteProjectWorker.postMessage({ messageType: 'open' }) + this.open() }) } @@ -81,6 +81,10 @@ class RemoteProject { return this.remoteProjectWorker.start({userID, serverURL, projectID}) } + open() { + this.remoteProjectWorker.postMessage({ messageType: 'open' }) + } + close() { this.remoteProjectWorker.postMessage({ messageType: 'close' }) } diff --git a/public/main-process/WorkerWindow.js b/public/main-process/WorkerWindow.js index 292c1a21..e68c5385 100644 --- a/public/main-process/WorkerWindow.js +++ b/public/main-process/WorkerWindow.js @@ -23,7 +23,7 @@ class WorkerWindow { ipcMain.on('close-worker-window', this.closeMessageHandler) this.workerWindow = new BrowserWindow({ - show: false, + show: true, webPreferences: { webSecurity: !this.debug, nodeIntegration: true, diff --git a/src/components/main-window/MainWindow.js b/src/components/main-window/MainWindow.js index 461eaed7..9d622731 100644 --- a/src/components/main-window/MainWindow.js +++ b/src/components/main-window/MainWindow.js @@ -398,20 +398,21 @@ export default class MainWindow extends Component { } onLoggedIn = () => { - const { resourceViews } = this.state - const { currentView } = resourceViews - const resourceView = resourceViews[currentView] - const { indexParentID, parentEntry, currentPage } = resourceView - const resourceViewRequest = { currentView, indexParentID, parentEntry, currentPage } this.setState( {...this.state, loginMode: false} ) - fairCopy.services.ipcSend('requestResourceView', resourceViewRequest ) + fairCopy.services.ipcSend('reopenProject') } onLogOut = () => { + const { resourceViews } = this.state const { fairCopyProject } = this.props const { userID, serverURL } = fairCopyProject + const { currentView } = resourceViews + const resourceView = resourceViews[currentView] + const { indexParentID, parentEntry, currentPage } = resourceView + const resourceViewRequest = { currentView, indexParentID, parentEntry, currentPage } logout(userID, serverURL) this.setState( {...this.state} ) + fairCopy.services.ipcSend('requestResourceView', resourceViewRequest ) } onEditResource = () => { diff --git a/src/components/main-window/dialogs/LoginDialog.js b/src/components/main-window/dialogs/LoginDialog.js index c2bd2cc0..385762f7 100644 --- a/src/components/main-window/dialogs/LoginDialog.js +++ b/src/components/main-window/dialogs/LoginDialog.js @@ -37,6 +37,11 @@ export default class LoginDialog extends Component { const value = e.currentTarget.value this.setState({...this.state, email: value }) } + const onKeyPress = (e) => { + if( e.key === 'Enter' ) { + onLogin() + } + } const { email, password } = this.state const saveAllowed = ( password.length > 0 && email.length > 0 ) @@ -49,7 +54,7 @@ export default class LoginDialog extends Component { aria-labelledby="login-title" > Login to Remote Server - +
  • Log Out : } @@ -279,13 +276,19 @@ export default class ResourceBrowser extends Component { } renderEmptyListMessage() { - const { resourceIndex, currentView } = this.props - if( resourceIndex.length > 0 || currentView !== 'home' ) return null + const { resourceIndex, currentView, fairCopyProject, resourceView } = this.props + if( resourceIndex.length > 0 || resourceView.loading ) return null + + const message = currentView === 'home' ? + There are no local resources. Click on the icon to see resources on the server. : + fairCopyProject.isLoggedIn() ? + There are no remote resources. On the Local page, you can create or import new resources to add to your project. : + You are not logged into the server. Click on the LOG IN button above. return ( - There are no local resources. Click on the icon to see resources on the server. + { message } ) diff --git a/src/components/project-settings-window/ProjectSettingsWindow.js b/src/components/project-settings-window/ProjectSettingsWindow.js index d04bb0b8..9e61ff88 100644 --- a/src/components/project-settings-window/ProjectSettingsWindow.js +++ b/src/components/project-settings-window/ProjectSettingsWindow.js @@ -90,6 +90,7 @@ export default class ProjectSettingsWindow extends Component { const { permissions, configLastAction, userID, remote } = fairCopyProject const canConfig = canConfigAdmin(permissions) const lockStatus = getConfigStatus( configLastAction, userID ) + const loggedIn = fairCopyProject.isLoggedIn() const onSaveConfig = () => { const { fairCopyConfig, projectInfo } = this.state @@ -112,7 +113,7 @@ export default class ProjectSettingsWindow extends Component { return (
    - { remote && canConfig &&
    + { remote && loggedIn && canConfig &&
    { checkOutError && Error: {checkOutError}}
    } diff --git a/src/model/cloud-api/config.js b/src/model/cloud-api/config.js index 58044c69..fa17a48b 100644 --- a/src/model/cloud-api/config.js +++ b/src/model/cloud-api/config.js @@ -5,7 +5,7 @@ import { standardErrorHandler } from './error-handler'; const configFilename = 'config-settings.json' -export function getConfig( projectID, serverURL, authToken, onSuccess, onFail) { +export function getConfig( userID, projectID, serverURL, authToken, onSuccess, onFail) { const getConfigURL = `${serverURL}/api/configs/${projectID}` axios.get(getConfigURL,authConfig(authToken)).then( @@ -13,11 +13,11 @@ export function getConfig( projectID, serverURL, authToken, onSuccess, onFail) { const { config_content, last_action } = okResponse.data.config onSuccess(config_content, last_action) }, - standardErrorHandler(onFail) + standardErrorHandler(userID, serverURL, onFail) ) } -export function initConfig( fairCopyConfig, projectID, serverURL, authToken, onSuccess, onFail) { +export function initConfig( fairCopyConfig, userID, projectID, serverURL, authToken, onSuccess, onFail) { const initConfigURL = `${serverURL}/api/configs` const configObj = { @@ -34,11 +34,11 @@ export function initConfig( fairCopyConfig, projectID, serverURL, authToken, onS const { config_content, last_action } = okResponse.data.config onSuccess(config_content, last_action) }, - standardErrorHandler(onFail) + standardErrorHandler(userID, serverURL, onFail) ) } -export function checkInConfig(fairCopyConfig, projectID, serverURL, authToken, onSuccess, onFail) { +export function checkInConfig(fairCopyConfig, userID, projectID, serverURL, authToken, onSuccess, onFail) { const checkInConfigURL = `${serverURL}/api/configs/${projectID}` const configObj = { @@ -53,11 +53,11 @@ export function checkInConfig(fairCopyConfig, projectID, serverURL, authToken, o const { config_content, last_action } = okResponse.data.config onSuccess(config_content, last_action) }, - standardErrorHandler(onFail) + standardErrorHandler(userID, serverURL, onFail) ) } -export function checkOutConfig(projectID, serverURL, authToken, onSuccess, onFail) { +export function checkOutConfig(projectID, userID, serverURL, authToken, onSuccess, onFail) { const checkOutConfigURL = `${serverURL}/api/configs/check_out/${projectID}` const configObj = { @@ -71,6 +71,6 @@ export function checkOutConfig(projectID, serverURL, authToken, onSuccess, onFai const { status } = okResponse.data.configs onSuccess(status) }, - standardErrorHandler(onFail) + standardErrorHandler(userID, serverURL, onFail) ) } \ No newline at end of file diff --git a/src/model/cloud-api/error-handler.js b/src/model/cloud-api/error-handler.js index ad1bfa67..d0c45b69 100644 --- a/src/model/cloud-api/error-handler.js +++ b/src/model/cloud-api/error-handler.js @@ -1,15 +1,18 @@ -// a standard function for passing on error responses +import { logout } from "./auth" -export function standardErrorHandler(onFail) { +// a standard function for passing on error responses +export function standardErrorHandler(userID,serverURL,onFail) { return (errorResponse) => { if( errorResponse && errorResponse.response ) { - if( errorResponse.response.status === 401 ) { - const { error } = errorResponse.response.data - onFail(error) + const { error } = errorResponse.response.data + const notAuthorized = (errorResponse.response.status === 401 ) + if( notAuthorized ) { + // user is either logged out on server or is making unauthorized requests + logout(userID,serverURL) } + onFail( error ) } else { onFail("Unable to connect to server.") } } -} - +} \ No newline at end of file diff --git a/src/model/cloud-api/id-map.js b/src/model/cloud-api/id-map.js index 7b909868..de90e16a 100644 --- a/src/model/cloud-api/id-map.js +++ b/src/model/cloud-api/id-map.js @@ -1,8 +1,9 @@ import axios from 'axios'; import { authConfig } from './auth' +import { standardErrorHandler } from './error-handler'; -export function getIDMap(serverURL, authToken, projectID, onSuccess, onFail) { +export function getIDMap(userID, serverURL, authToken, projectID, onSuccess, onFail) { const getIDMapURL = `${serverURL}/api/id_map/${projectID}` axios.get(getIDMapURL,authConfig(authToken)).then( @@ -10,15 +11,6 @@ export function getIDMap(serverURL, authToken, projectID, onSuccess, onFail) { const { id_map } = okResponse.data onSuccess(id_map) }, - (errorResponse) => { - if( errorResponse && errorResponse.response ) { - if( errorResponse.response.status === 401 ) { - const { error } = errorResponse.response.data - onFail(error) - } - } else { - onFail("Unable to connect to server.") - } - } + standardErrorHandler(userID, serverURL, onFail) ) } \ No newline at end of file diff --git a/src/model/cloud-api/projects.js b/src/model/cloud-api/projects.js index f1ce8bdc..b13523d1 100644 --- a/src/model/cloud-api/projects.js +++ b/src/model/cloud-api/projects.js @@ -16,7 +16,7 @@ export function getProjects( userID, serverURL, authToken, onSuccess, onFail) { } onSuccess(projectInfos) }, - standardErrorHandler(onFail) + standardErrorHandler(userID, serverURL, onFail) ) } @@ -29,7 +29,7 @@ export function getProject( userID, projectID, serverURL, authToken, onSuccess, const projectInfo = createProjectInfo(userID, project) onSuccess(projectInfo) }, - standardErrorHandler(onFail) + standardErrorHandler(userID, serverURL, onFail) ) } diff --git a/src/model/cloud-api/resource-management.js b/src/model/cloud-api/resource-management.js index b48f7f53..48b56d07 100644 --- a/src/model/cloud-api/resource-management.js +++ b/src/model/cloud-api/resource-management.js @@ -1,8 +1,9 @@ import axios from 'axios'; import { authConfig } from './auth' +import { standardErrorHandler } from './error-handler'; -export function checkInResources(serverURL, authToken, projectID, resources, message, onSuccess, onFail) { +export function checkInResources(userID, serverURL, authToken, projectID, resources, message, onSuccess, onFail) { const resourceObjs = resources.map( (resource) => { const { id, name, action, localID, parentID, resourceType, resourceMap, content } = resource @@ -37,13 +38,7 @@ export function checkInResources(serverURL, authToken, projectID, resources, mes onFail("Unable to check in resources.", resourceState) } }, - (errorResponse) => { - if( errorResponse && errorResponse.message ) { - onFail(errorResponse.message) - } else { - onFail("Unable to connect to server.") - } - } + standardErrorHandler(userID, serverURL, onFail) ) } diff --git a/src/model/cloud-api/resources.js b/src/model/cloud-api/resources.js index b4709fa5..dc5a117f 100644 --- a/src/model/cloud-api/resources.js +++ b/src/model/cloud-api/resources.js @@ -1,10 +1,11 @@ import axios from 'axios'; import { authConfig } from './auth' +import { standardErrorHandler } from './error-handler'; const maxResourcesPerPage = 9999 -export function getResources(serverURL, authToken, projectID, indexParentID, currentPage, rowsPerPage, onSuccess, onFail) { +export function getResources(userID, serverURL, authToken, projectID, indexParentID, currentPage, rowsPerPage, onSuccess, onFail) { const parentQ = indexParentID ? `/${indexParentID}` : '/null' const getProjectsURL = `${serverURL}/api/resources/by_project_by_parent/${projectID}${parentQ}?per_page=${rowsPerPage}&page=${currentPage}` @@ -16,20 +17,11 @@ export function getResources(serverURL, authToken, projectID, indexParentID, cur const parentEntry = indexParentID !== null && resources.length > 0 ? createResourceEntry( resources[0].parent_resource ) : null onSuccess({ parentEntry, totalRows, remoteResources }) }, - (errorResponse) => { - if( errorResponse && errorResponse.response ) { - if( errorResponse.response.status === 401 ) { - const { error } = errorResponse.response.data - onFail(error) - } - } else { - onFail("Unable to connect to server.") - } - } + standardErrorHandler(userID, serverURL, onFail) ) } -export function getResource(serverURL, authToken, resourceID, onSuccess, onFail) { +export function getResource( userID, serverURL, authToken, resourceID, onSuccess, onFail) { const getResourceURL = `${serverURL}/api/resources/${resourceID}` axios.get(getResourceURL,authConfig(authToken)).then( @@ -40,22 +32,13 @@ export function getResource(serverURL, authToken, resourceID, onSuccess, onFail) const parentEntry = parent_resource ? createResourceEntry(parent_resource) : null onSuccess({resourceEntry,parentEntry,content}) }, - (errorResponse) => { - if( errorResponse && errorResponse.response ) { - if( errorResponse.response.status === 401 ) { - const { error } = errorResponse.response.data - onFail(error) - } - } else { - onFail("Unable to connect to server.") - } - } + standardErrorHandler( userID, serverURL, onFail) ) } -export async function getResourcesAsync(serverURL, authToken, projectID, resourceID, currentPage, rowsPerPage=maxResourcesPerPage ) { +export async function getResourcesAsync( userID, serverURL, authToken, projectID, resourceID, currentPage, rowsPerPage=maxResourcesPerPage ) { return new Promise( ( resolve, reject ) => { - getResources( serverURL, authToken, projectID, resourceID, currentPage, rowsPerPage, (remoteResources) => { + getResources( userID, serverURL, authToken, projectID, resourceID, currentPage, rowsPerPage, (remoteResources) => { resolve(remoteResources) }, (errorMessage) => { reject(new Error(errorMessage)) @@ -63,9 +46,9 @@ export async function getResourcesAsync(serverURL, authToken, projectID, resourc }) } -export async function getResourceAsync(serverURL, authToken, resourceID) { +export async function getResourceAsync( userID, serverURL, authToken, resourceID) { return new Promise( ( resolve, reject ) => { - getResource( serverURL, authToken, resourceID, (remoteResource) => { + getResource( userID, serverURL, authToken, resourceID, (remoteResource) => { resolve(remoteResource) }, (errorMessage) => { reject(new Error(errorMessage)) diff --git a/src/workers/project-archive-worker.js b/src/workers/project-archive-worker.js index bd7bb0c7..b21d22d4 100644 --- a/src/workers/project-archive-worker.js +++ b/src/workers/project-archive-worker.js @@ -66,7 +66,7 @@ async function checkIn( userID, serverURL, projectID, committedResources, messag } } - checkInResources(serverURL, authToken, projectID, committedResources, message, onSuccess, onFail) + checkInResources(userID, serverURL, authToken, projectID, committedResources, message, onSuccess, onFail) } else { postMessage({ messageType: 'check-in-results', resourceIDs: [], error: "User not logged in." }) } @@ -81,7 +81,7 @@ async function checkOut( userID, serverURL, projectID, resourceEntries, zip, pos for( const resourceEntry of resourceEntries ) { const { id: resourceID, type } = resourceEntry if( type === 'teidoc' ) { - const resourceData = await getResourcesAsync(serverURL, authToken, projectID, resourceID, 1) + const resourceData = await getResourcesAsync( userID, serverURL, authToken, projectID, resourceID, 1) for( const resource of resourceData.remoteResources ) { if( resource.type !== 'header' ) resourceIDs.push(resource.id) } @@ -96,7 +96,7 @@ async function checkOut( userID, serverURL, projectID, resourceEntries, zip, pos // get the contents for each resource and add them to the project for( const resourceState of resourceStates ) { const { resource_guid: resourceID, state } = resourceState - const { resourceEntry, parentEntry, content } = await getResourceAsync( serverURL, authToken, resourceID ) + const { resourceEntry, parentEntry, content } = await getResourceAsync( userID, serverURL, authToken, resourceID ) if( state === 'success') { resources[resourceEntry.id] = { state, resourceEntry, parentEntry, content } @@ -128,7 +128,7 @@ async function prepareResourceExport( resourceEntry, projectData, zip ) { } if( resourceEntry.type === 'teidoc' ) { - const resourceData = await getResourcesAsync(serverURL, authToken, projectID, resourceEntry.id, 1) + const resourceData = await getResourcesAsync( userID, serverURL, authToken, projectID, resourceEntry.id, 1) const { remoteResources } = resourceData for( const remoteEntry of remoteResources ) { @@ -139,7 +139,7 @@ async function prepareResourceExport( resourceEntry, projectData, zip ) { contents[resourceID] = await readUTF8( resourceID, zip ) } else { childEntries.push(remoteEntry) - const remoteResource = await getResourceAsync(serverURL,authToken,resourceID) + const remoteResource = await getResourceAsync( userID, serverURL,authToken,resourceID) contents[resourceID] = remoteResource.content } } @@ -149,7 +149,7 @@ async function prepareResourceExport( resourceEntry, projectData, zip ) { if( localEntry ) { contents[resourceID] = await readUTF8( resourceID, zip ) } else { - const remoteResource = await getResourceAsync(serverURL,authToken,resourceID) + const remoteResource = await getResourceAsync( userID, serverURL,authToken,resourceID) contents[resourceID] = remoteResource.content } } diff --git a/src/workers/remote-project-worker.js b/src/workers/remote-project-worker.js index 0e90b4f7..97651a48 100644 --- a/src/workers/remote-project-worker.js +++ b/src/workers/remote-project-worker.js @@ -5,18 +5,18 @@ import { getIDMap } from "../model/cloud-api/id-map" import { connectCable } from "../model/cloud-api/activity-cable" import { getConfig, initConfig, checkInConfig, checkOutConfig } from "../model/cloud-api/config" -function updateIDMap( serverURL, authToken, projectID, postMessage) { - getIDMap(serverURL, authToken, projectID, (idMapData) => { +function updateIDMap( userID, serverURL, authToken, projectID, postMessage) { + getIDMap( userID, serverURL, authToken, projectID, (idMapData) => { postMessage({ messageType: 'id-map-update', idMapData }) }, (error) => { - throw new Error(error) + console.log(error) }) } -function updateResourceView( serverURL, projectID, resourceView, authToken, postMessage ) { +function updateResourceView( userID, serverURL, projectID, resourceView, authToken, postMessage ) { if( authToken ) { const { currentPage, rowsPerPage, indexParentID } = resourceView - getResources( serverURL, authToken, projectID, indexParentID, currentPage, rowsPerPage, (resourceData) => { + getResources( userID, serverURL, authToken, projectID, indexParentID, currentPage, rowsPerPage, (resourceData) => { const { parentEntry, remoteResources, totalRows } = resourceData resourceView.parentEntry = parentEntry resourceView.totalRows = totalRows @@ -27,7 +27,15 @@ function updateResourceView( serverURL, projectID, resourceView, authToken, post console.log(error) }) } else { - throw new Error(`Recieved request-view message when user is not logged in.`) + // user is not logged in, remote list is empty + const emptyView = { indexParentID: null, + parentEntry: null, + currentPage: 1, + rowsPerPage: resourceView.rowsPerPage, + totalRows: 0, + loading: false + } + postMessage({ messageType: 'resource-view-update', resourceView: emptyView, remoteResources: [] }) } } @@ -40,8 +48,8 @@ function updateProjectInfo( userID, serverURL, authToken, projectID, postMessage }) } -function updateConfig( serverURL, authToken, projectID, postMessage ) { - getConfig(projectID, serverURL, authToken, (config, configLastAction) => { +function updateConfig( userID, serverURL, authToken, projectID, postMessage ) { + getConfig(userID, projectID, serverURL, authToken, (config, configLastAction) => { postMessage({ messageType: 'config-update', config, configLastAction }) }, (error) => { @@ -49,7 +57,7 @@ function updateConfig( serverURL, authToken, projectID, postMessage ) { }) } -function checkInFairCopyConfig( serverURL, projectID, fairCopyConfig, firstAction, authToken, postMessage ) { +function checkInFairCopyConfig( userID, serverURL, projectID, fairCopyConfig, firstAction, authToken, postMessage ) { const onSuccess = (config, configLastAction) => { postMessage({ messageType: 'config-update', config, configLastAction }) } @@ -60,13 +68,13 @@ function checkInFairCopyConfig( serverURL, projectID, fairCopyConfig, firstActio } if( firstAction ) { - initConfig( fairCopyConfig, projectID, serverURL, authToken, onSuccess, onFail ) + initConfig( fairCopyConfig, userID, projectID, serverURL, authToken, onSuccess, onFail ) } else { - checkInConfig( fairCopyConfig, projectID, serverURL, authToken, onSuccess, onFail ) + checkInConfig( fairCopyConfig, userID, projectID, serverURL, authToken, onSuccess, onFail ) } } -function checkOutFairCopyConfig( serverURL, projectID, authToken, postMessage ) { +function checkOutFairCopyConfig( userID, serverURL, projectID, authToken, postMessage ) { const onSuccess = (status) => { postMessage({ messageType: 'config-check-out-result', status }) } @@ -76,7 +84,28 @@ function checkOutFairCopyConfig( serverURL, projectID, authToken, postMessage ) console.log(error) } - checkOutConfig( projectID, serverURL, authToken, onSuccess, onFail ) + checkOutConfig( projectID, userID, serverURL, authToken, onSuccess, onFail ) +} + +function getParentResource( userID, serverURL, authToken, resourceEntry, content, xmlID, postMessage) { + getResource( userID, serverURL, authToken, resourceEntry.parentResource, (response) => { + const { resourceEntry: parentEntry } = response + postMessage({ messageType: 'got-parent', resourceEntry, parentEntry, content, xmlID }) + }, (errorMessage) => { + const parentEntry = { + id: resourceEntry.parentResource, + localID: '___offline___', + name: '*OFFLINE*', + type: 'teidoc', + remote: true, + parentResource: null, + deleted: false, + gitHeadRevision: null, + lastAction: null + } + postMessage({ messageType: 'got-parent', resourceEntry, parentEntry, content, xmlID }) + console.log(errorMessage) + }) } const onNotification = (data, workerData, postMessage) => { @@ -86,7 +115,7 @@ const onNotification = (data, workerData, postMessage) => { if( notification === "resources_checked_in" ) { const { resources } = data - updateIDMap( serverURL, authToken, projectID, postMessage ) + updateIDMap( userID, serverURL, authToken, projectID, postMessage ) postMessage({ messageType: 'resources-updated', resources }) } // other possible notifications: @@ -104,50 +133,38 @@ export function remoteProject( msg, workerMethods, workerData ) { switch( messageType ) { case 'open': - // timeout is just for debugging - setTimeout( () => { - updateProjectInfo(userID, serverURL, authToken, projectID, postMessage) - updateConfig( serverURL, authToken, projectID, postMessage ) - updateIDMap( serverURL, authToken, projectID, postMessage ) - connectCable(projectID, serverURL, authToken, (data) => onNotification( data, workerData, postMessage ) ) - }, 2000) + updateProjectInfo(userID, serverURL, authToken, projectID, postMessage) + updateConfig( userID, serverURL, authToken, projectID, postMessage ) + updateIDMap( userID, serverURL, authToken, projectID, postMessage ) + connectCable(projectID, serverURL, authToken, (data) => onNotification( data, workerData, postMessage ) ) break case 'get-resource': - if( authToken ) { + { const { resourceID, xmlID } = msg - getResource(serverURL, authToken, resourceID, (response) => { + getResource( userID, serverURL, authToken, resourceID, (response) => { const { resourceEntry, parentEntry, content } = response postMessage({ messageType: 'resource-data', resourceEntry, parentEntry, content, xmlID }) }, (errorMessage) => { - // TODO handle errors - }) - } else { - throw new Error(`Recieved get-resource message when user is not logged in.`) - } - break - case 'get-parent': - if( authToken ) { - const { resourceEntry, content, xmlID } = msg - getResource(serverURL, authToken, resourceEntry.parentResource, (response) => { - const { resourceEntry: parentEntry } = response - postMessage({ messageType: 'got-parent', resourceEntry, parentEntry, content, xmlID }) - }, (errorMessage) => { - // TODO handle errors + console.log(errorMessage) }) - } else { - throw new Error(`Recieved get-parent message when user is not logged in.`) + } + break + case 'get-parent': + { + const { resourceEntry, content, xmlID } = msg + getParentResource( userID, serverURL, authToken, resourceEntry, content, xmlID, postMessage) } break case 'checkin-config': const { config:fairCopyConfig, firstAction } = msg - checkInFairCopyConfig( serverURL, projectID, fairCopyConfig, firstAction, authToken, postMessage ) + checkInFairCopyConfig( userID, serverURL, projectID, fairCopyConfig, firstAction, authToken, postMessage ) break case 'checkout-config': - checkOutFairCopyConfig( serverURL, projectID, authToken, postMessage ) + checkOutFairCopyConfig( userID, serverURL, projectID, authToken, postMessage ) break case 'request-view': const { resourceView } = msg - updateResourceView( serverURL, projectID, resourceView, authToken, postMessage ) + updateResourceView( userID, serverURL, projectID, resourceView, authToken, postMessage ) break case 'close': close() From b169736c101e428a5c1dc4de11e99faf02951221 Mon Sep 17 00:00:00 2001 From: Nick Laiacona Date: Thu, 2 Feb 2023 07:57:13 -0500 Subject: [PATCH 4/8] Add User Configurable Hotkeys (#501) --- .vscode/launch.json | 2 +- package.json | 9 +- public/css/KeyBindingDialog.css | 17 ++ public/css/KeyBindingsTable.css | 15 ++ public/css/index.css | 4 +- public/main-process/WorkerWindow.js | 2 +- .../main-process/config/faircopy-config.json | 1 + public/main-process/data-migration.js | 9 + src/components/App.js | 6 + .../main-window/tei-editor/EditorToolbar.js | 57 +++-- .../main-window/tei-editor/ElementMenu.js | 53 ++--- .../main-window/tei-editor/EmptyGroup.js | 2 + .../main-window/tei-editor/NotePopup.js | 62 +++--- .../tei-editor/StructurePalette.js | 3 +- .../main-window/tei-editor/TEIEditor.js | 199 ++++++++---------- .../project-settings-window/ElementLibrary.js | 4 +- .../project-settings-window/ElementTree.js | 3 +- .../KeyBindingDialog.js | 179 ++++++++++++++++ .../KeyBindingsTable.js | 106 ++++++++++ .../ProjectSettingsWindow.js | 16 +- src/model/TEISchema.js | 17 +- src/model/editor-keybindings.js | 48 +++++ src/model/editor-navigation.js | 89 ++++---- yarn.lock | 9 +- 24 files changed, 656 insertions(+), 256 deletions(-) create mode 100644 public/css/KeyBindingDialog.css create mode 100644 public/css/KeyBindingsTable.css create mode 100644 src/components/project-settings-window/KeyBindingDialog.js create mode 100644 src/components/project-settings-window/KeyBindingsTable.js create mode 100644 src/model/editor-keybindings.js diff --git a/.vscode/launch.json b/.vscode/launch.json index 31f31fa1..7aa77f1a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -40,7 +40,7 @@ "windows": { "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" }, - "env": { "FAIRCOPY_DEBUG_MODE": "true", "FAIRCOPY_DEV_VERSION": "1.1.5" }, + "env": { "FAIRCOPY_DEBUG_MODE": "true", "FAIRCOPY_DEV_VERSION": "1.1.6" }, "program": "${workspaceRoot}/public/electron.js", "protocol": "inspector", } diff --git a/package.json b/package.json index 6ca312db..56488b59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "faircopy", - "version": "1.1.5-dev.1", + "version": "1.1.6-dev.1", "description": "A word processor for the humanities scholar.", "main": "public/electron.js", "private": true, @@ -36,7 +36,6 @@ "@material-ui/core": "^4.9.14", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.53", - "react-spring": "9.5.5", "@rails/actioncable": "^6.0.5", "annotorious-openseadragon": "github:performant-software/annotorious-openseadragon", "axios": "^0.19.2", @@ -64,10 +63,12 @@ "react-beautiful-dnd": "^13.0.0", "react-dom": "^16.13.1", "react-force-graph-2d": "^1.13.6", + "react-hotkeys": "^2.0.0", "react-markdown": "^6.0.0", "react-scripts": "3.4.1", "react-scroll": "1.8.3", "react-split-pane": "^0.1.92", + "react-spring": "9.5.5", "semver": "^7.3.5", "uuid": "^8.1.0", "w3c-xmlserializer": "^2.0.0", @@ -106,7 +107,9 @@ "win": { "icon": "build/icon.ico", "certificateSubjectName": "Performant Software Solutions LLC", - "signingHashAlgorithms": ["sha256"], + "signingHashAlgorithms": [ + "sha256" + ], "publisherName": "Performant Software Solutions LLC", "signAndEditExecutable": true, "target": [ diff --git a/public/css/KeyBindingDialog.css b/public/css/KeyBindingDialog.css new file mode 100644 index 00000000..a1d5ef27 --- /dev/null +++ b/public/css/KeyBindingDialog.css @@ -0,0 +1,17 @@ +#KeyBindingDialog .element-field { + display: inline-block; +} + +#KeyBindingDialog .keystroke-record-button { + margin-top: 20px; +} + +#KeyBindingDialog .record-icon { + margin-right: 5px; + color: red; +} + +#KeyBindingDialog .error-message { + margin: 5px; + color: red; +} \ No newline at end of file diff --git a/public/css/KeyBindingsTable.css b/public/css/KeyBindingsTable.css new file mode 100644 index 00000000..74d28d84 --- /dev/null +++ b/public/css/KeyBindingsTable.css @@ -0,0 +1,15 @@ +#KeyBindingsTable { + margin: 20px; +} + +#KeyBindingsTable .explanation { + margin-bottom: 50px; +} + +#KeyBindingsTable th { + font-weight: bold; +} + +#KeyBindingsTable .add-keybinding-button { + margin-top: 10px; +} \ No newline at end of file diff --git a/public/css/index.css b/public/css/index.css index 1ff7ccff..f882da55 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -58,4 +58,6 @@ @import "SelectRemoteProjectPanel.css"; @import "ChooseLocalFilePanel.css"; @import "IIIFTreeView.css"; -@import "IIIFImportDialog.css" \ No newline at end of file +@import "IIIFImportDialog.css"; +@import "KeyBindingsTable.css"; +@import "KeyBindingDialog.css"; \ No newline at end of file diff --git a/public/main-process/WorkerWindow.js b/public/main-process/WorkerWindow.js index e68c5385..292c1a21 100644 --- a/public/main-process/WorkerWindow.js +++ b/public/main-process/WorkerWindow.js @@ -23,7 +23,7 @@ class WorkerWindow { ipcMain.on('close-worker-window', this.closeMessageHandler) this.workerWindow = new BrowserWindow({ - show: true, + show: false, webPreferences: { webSecurity: !this.debug, nodeIntegration: true, diff --git a/public/main-process/config/faircopy-config.json b/public/main-process/config/faircopy-config.json index df915e3a..7b8267d0 100644 --- a/public/main-process/config/faircopy-config.json +++ b/public/main-process/config/faircopy-config.json @@ -1,4 +1,5 @@ { + "keybindings": {}, "menus": { "mark": [ { diff --git a/public/main-process/data-migration.js b/public/main-process/data-migration.js index fdf64dea..9fff5a1c 100644 --- a/public/main-process/data-migration.js +++ b/public/main-process/data-migration.js @@ -22,6 +22,11 @@ const migrateConfig = function migrateConfig( generatedWith, baseConfig, project migrationRemoveElements(projectConfig,baseConfig) migrationAddNewElements(baseConfig,projectConfig) + if( semver.lt(projectVersion,'1.1.6') ) { + migrationAddKeybindings(projectConfig) + log.info('applying migrations for v1.1.6') + } + if( semver.lt(projectVersion,'0.10.1') ) { migrationAddMenus(projectConfig,baseConfig) migrationAddActiveState(projectConfig) @@ -65,6 +70,10 @@ exports.migrateManifestData = migrateManifestData //// MIGRATIONS ///////////////////////////////////////////////// +function migrationAddKeybindings(projectConfig) { + projectConfig.keybindings = {} +} + function migrationAddNewElements(baseConfig,projectConfig) { const baseElements = Object.keys(baseConfig.elements) const projectElements = Object.keys(projectConfig.elements) diff --git a/src/components/App.js b/src/components/App.js index ba4a6d67..88ef038a 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -1,4 +1,6 @@ import React, { Component } from 'react' +import { configure } from 'react-hotkeys' + import MainWindow from './main-window/MainWindow' import ImageWindow from './image-window/ImageWindow' import ProjectWindow from './project-window/ProjectWindow' @@ -12,6 +14,7 @@ import ImageView from '../model/ImageView' import { initLicenseData, licenseLock } from '../model/license-key' import { getConfigStatus } from '../model/faircopy-config' + const fairCopy = window.fairCopy export default class App extends Component { @@ -99,6 +102,9 @@ export default class App extends Component { if( rootComponent === 'MainWindow' ) { services.ipcRegisterCallback('projectOpened', (event, projectData) => this.openProject(projectData)) services.ipcRegisterCallback('projectIncompatible', (event, incompatInfo) => this.setState({ ...this.state, incompatInfo }) ) + + // configure hot keys to accept input from all element types + configure({ ignoreEventsCondition: () => false }) } else if( rootComponent === 'ImageWindow' ) { services.ipcRegisterCallback('imageViewOpened', (event, imageViewData) => this.openImageView(imageViewData)) } diff --git a/src/components/main-window/tei-editor/EditorToolbar.js b/src/components/main-window/tei-editor/EditorToolbar.js index 84740748..a84e92c8 100644 --- a/src/components/main-window/tei-editor/EditorToolbar.js +++ b/src/components/main-window/tei-editor/EditorToolbar.js @@ -5,6 +5,7 @@ import { IconButton, Tooltip } from '@material-ui/core' import {undo, redo} from "prosemirror-history" import { createPhraseElement, eraseSelection } from "../../../model/editor-actions" import { getEnabledMenus } from '../../../model/editor-navigation' +import { validAction } from '../../../model/element-validators' import ElementMenu from "./ElementMenu" export default class EditorToolbar extends Component { @@ -107,17 +108,29 @@ export default class EditorToolbar extends Component { } } } - + render() { const { onEditResource, onSave, teiDocument, onProjectSettings, onCloseElementMenu, elementMenuOptions } = this.props - const { changedSinceLastSave } = teiDocument + const { changedSinceLastSave, fairCopyProject } = teiDocument - const seperator =
    + const seperator =
    - const onBold = ()=> { this.createMark('hi', {rend: 'bold'})} - const onItalic = ()=> { this.createMark('hi', {rend: 'italic'})} - const onUnderline = ()=> { this.createMark('hi',{rend:'underline'})} - const onRef = ()=> { this.createMark('ref',{})} + const onBold = ()=> { this.createMark('hi', {rend: 'bold'})} + const onItalic = ()=> { this.createMark('hi', {rend: 'italic'})} + const onUnderline = ()=> { this.createMark('hi',{rend:'underline'})} + const onRef = ()=> { this.createMark('ref',{})} + + const editorView = teiDocument.getActiveView() + const { menus } = fairCopyProject.fairCopyConfig + const { elements } = fairCopyProject.teiSchema + + const onAction = (member) => { + const selection = (editorView) ? editorView.state.selection : null + if( selection && !selection.node ) { + createPhraseElement(member, {}, teiDocument) + } + onCloseElementMenu() + } return (
    @@ -137,17 +150,25 @@ export default class EditorToolbar extends Component { { this.renderButton("Edit Properties", "fas fa-edit", onEditResource ) } { this.renderButton("Save", "fas fa-save", onSave, changedSinceLastSave ) }
    - { - onProjectSettings() - this.setState({...this.state, elementMenuOptions: null }) } - } - {...elementMenuOptions} - > + { elementMenuOptions && { + return validAction( elementID, teiDocument ) + }} + onProjectSettings={() => { + onProjectSettings() + this.setState({...this.state, elementMenuOptions: null }) } + } + onExited={() => { + // return focus to active editor after menu closes + editorView.focus() + }} + {...elementMenuOptions} + > }
    ) } diff --git a/src/components/main-window/tei-editor/ElementMenu.js b/src/components/main-window/tei-editor/ElementMenu.js index 7cf17417..69bc9bf8 100644 --- a/src/components/main-window/tei-editor/ElementMenu.js +++ b/src/components/main-window/tei-editor/ElementMenu.js @@ -3,8 +3,7 @@ import { Menu, MenuItem, Typography } from '@material-ui/core' import ElementInfoPopup from './ElementInfoPopup' import EmptyGroup from './EmptyGroup'; -import { createPhraseElement } from "../../../model/editor-actions" -import { validAction } from '../../../model/element-validators' +import { getElementIcon } from '../../../model/TEISchema'; export default class ElementMenu extends Component { @@ -19,20 +18,18 @@ export default class ElementMenu extends Component { } getMenuGroups() { - const { teiDocument, menuGroup } = this.props - if( !teiDocument || !menuGroup ) return [] - const {menus} = teiDocument.fairCopyProject.fairCopyConfig + const { menus, menuGroup } = this.props + if( !menuGroup ) return [] return menus[menuGroup] } renderElementInfo() { - const { teiDocument } = this.props + const { elements } = this.props const { elementInfoID } = this.state const anchorEl = this.itemEls[elementInfoID] if( elementInfoID === null || !anchorEl ) return null - const { elements } = teiDocument.fairCopyProject.teiSchema const elementSpec = elements[elementInfoID] const onAnchorEl = () => { @@ -50,21 +47,9 @@ export default class ElementMenu extends Component { ) } - createMenuAction(selection,member) { - return () => { - const { teiDocument, onClose } = this.props - - if( selection && !selection.node ) { - createPhraseElement(member, {}, teiDocument) - } - onClose() - } - } - renderSubMenu() { const { subMenuID } = this.state - const { teiDocument, open } = this.props - const { teiSchema } = teiDocument.fairCopyProject + const { onAction, elements, validAction } = this.props const menuGroups = this.getMenuGroups() const anchorOrigin = { vertical: 'top', horizontal: 'right' } @@ -96,21 +81,19 @@ export default class ElementMenu extends Component { } for( const member of members ) { - const editorView = teiDocument.getActiveView() - const selection = (editorView) ? editorView.state.selection : null const setItemElRef = (el) => { this.itemEls[member] = el } - const valid = validAction( member, teiDocument ) + const valid = validAction( member ) const onShowInfo = () => { this.setState({ ...this.state, elementInfoID: member })} const onHideInfo = () => { this.setState({ ...this.state, elementInfoID: null })} const onKeyUp = (e) => { // left arrow if( e.keyCode === 37 ) onClose() } - const icon = teiSchema.getElementIcon(member) + const icon = getElementIcon(member, elements) const nameEl = icon ? {member} : {member} menuItems.push( @@ -123,7 +106,7 @@ export default class ElementMenu extends Component { onBlur={onHideInfo} onMouseOver={onShowInfo} onMouseLeave={onHideInfo} - onClick={this.createMenuAction(selection, member)} + onClick={() => { onAction(member) }} onKeyUp={onKeyUp} > {nameEl} @@ -133,7 +116,7 @@ export default class ElementMenu extends Component { return ( { - const { teiDocument } = this.props - const editorView = teiDocument.getActiveView() - editorView.focus() - } - - const onCloseMenu = () => { - this.setState({...this.state, subMenuID: null}) - onClose() - } - return (
    { - const { teiDocument, onTogglePalette, onOpenElementMenu, onClose } = this.props + getNoteHotKeyConfig() { + const { teiDocument, onTogglePalette, onOpenElementMenu, onClose, clipboardSerializer } = this.props - if( event.key === 'Escape' ) { + // get the base hotkey config + const { keyMap, handlers } = getHotKeyConfig( teiDocument, getEditorCommands( teiDocument, onTogglePalette, onOpenElementMenu, clipboardSerializer ) ) + + // add ESC hotkey + keyMap.closeNote = 'escape' + handlers.closeNote = () => { // move the selection past the inline node and return to main editor const {editorView} = teiDocument const {tr, selection} = editorView.state @@ -119,9 +126,8 @@ export default class NotePopup extends Component { onClose() editorView.focus() } - else { - return handleEditorHotKeys(event, teiDocument, onTogglePalette, onOpenElementMenu, this.clipboardSerializer ) - } + + return { keyMap, handlers } } renderEditor() { @@ -147,27 +153,35 @@ export default class NotePopup extends Component { this.el = el } + const { keyMap, handlers } = this.getNoteHotKeyConfig() + const gutterTop = (this.el) ? this.el.getBoundingClientRect().top - 5 : 0 // if( this.el ) console.log(`top: ${gutterTop}`) return ( -
    - - -
    + +
    + + +
    +
    ) } diff --git a/src/components/main-window/tei-editor/StructurePalette.js b/src/components/main-window/tei-editor/StructurePalette.js index 945efc04..9061a619 100644 --- a/src/components/main-window/tei-editor/StructurePalette.js +++ b/src/components/main-window/tei-editor/StructurePalette.js @@ -4,6 +4,7 @@ import EmptyGroup from './EmptyGroup' import ElementInfoPopup from './ElementInfoPopup' import { determineRules, createStructureElement } from '../../../model/editor-actions' import { validNodeAction } from '../../../model/element-validators' +import { getElementIcon } from '../../../model/TEISchema' const clientOffset = { x: 0, y: 0 } @@ -203,7 +204,7 @@ renderElement(elementID,groupID,paletteOrder) { const elType = elementGroups.hard.includes(elementID) ? 'hard' : 'soft' const className = `element-type ${elType}` - const icon = teiSchema.getElementIcon(elementID) + const icon = getElementIcon(elementID, teiSchema.elements) const elementIcon = icon ? : null return ( diff --git a/src/components/main-window/tei-editor/TEIEditor.js b/src/components/main-window/tei-editor/TEIEditor.js index c1e1f4f8..6176f825 100644 --- a/src/components/main-window/tei-editor/TEIEditor.js +++ b/src/components/main-window/tei-editor/TEIEditor.js @@ -1,7 +1,7 @@ import React, { Component } from 'react' import {EditorView} from "prosemirror-view" import { debounce } from "debounce"; -import {TextSelection} from "prosemirror-state" +import { HotKeys } from 'react-hotkeys' // import applyDevTools from "prosemirror-dev-tools"; @@ -15,10 +15,11 @@ import ReadOnlyToolbar from './ReadOnlyToolbar' import TitleBar from '../TitleBar' import NotePopup from './NotePopup' import { transformPastedHTMLHandler,transformPastedHandler, createClipboardSerializer } from "../../../model/cut-and-paste" -import { handleEditorHotKeys, navigateFromTreeToEditor, getSelectedElements, broadcastZoneLinks, navigateFromEditorToTree } from '../../../model/editor-navigation' +import { navigateFromTreeToEditor, getSelectedElements, broadcastZoneLinks, navigateFromEditorToTree, getEditorCommands, arrowNavToNote } from '../../../model/editor-navigation' import { findNoteNode } from '../../../model/xml' import { canConfigAdmin } from '../../../model/permissions' import { getConfigStatus } from '../../../model/faircopy-config' +import { getHotKeyConfig } from "../../../model/editor-keybindings" const resizeRefreshRate = 100 @@ -29,8 +30,6 @@ export default class TEIEditor extends Component { this.state = { noteID: null, notePopupAnchorEl: null, - ctrlDown: false, - altDown: false, elementMenuOptions: null, paletteWindowOpen: false, currentSubmenuID: 0 @@ -68,10 +67,22 @@ export default class TEIEditor extends Component { } } - // prevent text entry when a node is selected + // Since the editor isn't a react component, some hotkeys are handled by editor directly onEditorKeyDown = (editorView,e) => { + const { teiDocument } = this.props const selection = (editorView) ? editorView.state.selection : null const key = e.key + + if( key === 'ArrowLeft' ) { + arrowNavToNote( this.openNotePopup, teiDocument, -1 ) + return + } + + if( key === 'ArrowRight') { + arrowNavToNote( this.openNotePopup, teiDocument, 1 ) + return + } + if( selection && selection.node ) { return (key === "Backspace" || key === "Delete") ? false : true } @@ -204,7 +215,7 @@ export default class TEIEditor extends Component { this.setState({...this.state, paletteWindowOpen: !paletteWindowOpen}) } - openNotePopup(noteID, notePopupAnchorEl) { + openNotePopup = (noteID, notePopupAnchorEl) => { this.setState({...this.state, noteID, notePopupAnchorEl }) } @@ -215,47 +226,14 @@ export default class TEIEditor extends Component { } } - onKeyDown = ( event ) => { - const { teiDocument, altDown, ctrlDown } = this.props - const shiftKey = !!event.shiftKey - - if( event.altKey && !altDown ) { - this.setState({...this.state, altDown: true }) - } - if( event.ctrlKey && !ctrlDown ) { - this.setState({...this.state, ctrlDown: true }) - } - - // if we are on an aside, open it - if( event.key === 'ArrowRight' || event.key === 'ArrowLeft' ) { - const { editorView } = teiDocument - const { selection } = editorView.state - if( selection && selection.node ) { - const { node } = selection - const nodeName = node.type.name - - const {teiSchema} = teiDocument.fairCopyProject - if( teiSchema.elementGroups.asides.includes(nodeName) ) { - const noteID = node.attrs['__id__'] - const { $anchor } = selection - const anchorEl = editorView.nodeDOM($anchor.pos) - this.openNotePopup(noteID, anchorEl) - } - else { - const {editorView} = teiDocument - const {tr, selection} = editorView.state - const {$anchor} = selection - const direction = event.key === 'ArrowRight' ? 1 : -1 - tr.setSelection(TextSelection.create(tr.doc, $anchor.pos + direction)) - editorView.dispatch(tr) - } + getMainEditorHotKeyConfig() { + const { teiDocument } = this.props + + // get the base hotkey config + const { keyMap, handlers } = getHotKeyConfig( teiDocument, getEditorCommands( teiDocument, this.onTogglePalette, this.onOpenElementMenu, this.clipboardSerializer ) ) - return - } - } - - // Move from the editor to the tree w/ keyboard - if( event.key === 'Tab' && shiftKey ) { + keyMap.hopToTree = 'shift+tab' + handlers.hopToTree = () => { const { editorView } = teiDocument const { treeID } = teiDocument.currentTreeNode const { editorGutterPos } = teiDocument.currentTreeNode @@ -264,21 +242,9 @@ export default class TEIEditor extends Component { const { nextPos, nextPath } = navigateFromEditorToTree( editorView ) this.onChangePos(nextPos, nextPath, treeID) } - return - } - - handleEditorHotKeys(event, teiDocument, this.onTogglePalette, this.onOpenElementMenu, this.clipboardSerializer ); - } - - onKeyUp = ( event ) => { - const { ctrlDown, altDown } = this.state - - if( !event.altKey && altDown ) { - this.setState({...this.state, altDown: false }) - } - if( !event.ctrlKey && ctrlDown ) { - this.setState({...this.state, ctrlDown: false }) } + + return { keyMap, handlers } } onOpenElementMenu = (elementMenuOptions ) => { @@ -346,68 +312,73 @@ export default class TEIEditor extends Component { const canConfig = canConfigAdmin(permissions) const lockStatus = getConfigStatus( configLastAction, userID ) const canEditConfig = !remote || (canConfig && lockStatus === 'checked_out') + + const { keyMap, handlers } = this.getMainEditorHotKeyConfig() return (
    -
    - { !hidden && - - } - { !hidden && readOnly ? - : - - } -
    - { !hidden && } - - { !hidden && } +
    + { !hidden && + + } + { !hidden && readOnly ? + : + + } +
    + { !hidden && } + + { !hidden && } +
    + { !hidden && { this.drawerRef = el}} + noteID={noteID} + height={drawerHeight} + width={drawerWidthCSS} + readOnly={readOnly} + canEditConfig={canEditConfig} + /> }
    - { !hidden && { this.drawerRef = el}} - noteID={noteID} - height={drawerHeight} - width={drawerWidthCSS} - readOnly={readOnly} - canEditConfig={canEditConfig} - /> } -
    + { !hidden && : null const selected = ( elementID === selectedElement ) ? "selected-item" : "" diff --git a/src/components/project-settings-window/ElementTree.js b/src/components/project-settings-window/ElementTree.js index d7d15faa..c2c9662f 100644 --- a/src/components/project-settings-window/ElementTree.js +++ b/src/components/project-settings-window/ElementTree.js @@ -7,6 +7,7 @@ import { Droppable, Draggable } from "react-beautiful-dnd" import ElementInfoPopup from '../main-window/tei-editor/ElementInfoPopup'; import { removeGroupFromMenu } from '../../model/faircopy-config' import { determineRules } from '../../model/editor-actions' +import { getElementIcon } from '../../model/TEISchema'; export default class ElementTree extends Component { @@ -56,7 +57,7 @@ export default class ElementTree extends Component { renderElement(groupID,elementID,index) { const { teiSchema, onSelect, selectedElement, selectedGroup, readOnly } = this.props - const icon = teiSchema.getElementIcon(elementID) + const icon = getElementIcon(elementID, teiSchema.elements) const elementType = teiSchema.getElementType(elementID) const elementIcon = icon ? : null const elementKey = `group${groupID}_element-${elementID}` diff --git a/src/components/project-settings-window/KeyBindingDialog.js b/src/components/project-settings-window/KeyBindingDialog.js new file mode 100644 index 00000000..6e10e3c3 --- /dev/null +++ b/src/components/project-settings-window/KeyBindingDialog.js @@ -0,0 +1,179 @@ +import React, { Component } from 'react' + +import { Button, Typography } from '@material-ui/core' +import { Dialog, DialogActions, DialogContent, DialogTitle } from '@material-ui/core' +import { Table, TableContainer, TableBody, TableCell, TableRow, TableHead, Paper } from '@material-ui/core' +import {recordKeyCombination} from 'react-hotkeys' + +import ElementMenu from "../main-window/tei-editor/ElementMenu" + +const modifierKeys = [ 'Meta', 'Alt', 'Control' ] + +export default class KeyBindingDialog extends Component { + + constructor(props) { + super(props) + + const { selectedKey, selectedAction } = this.props + const elementType = selectedAction ? selectedAction.elementType : 'mark' + const elementName = selectedAction ? selectedAction.elementName : null + const title = selectedKey ? "Edit Keybinding" : "New Keybinding" + + this.state = { + title, + chord: selectedKey, + recordingChord: false, + elementType, + elementName, + elementMenuOptions: null, + errorMessage: null + } + this.elementMenuAnchors = {} + } + + renderChordField() { + const { assignedKeys } = this.props + const { recordingChord } = this.state + + const onClick = () => { + recordKeyCombination( (e) => { + const {id: chord} = e + console.log(chord) + if( !assignedKeys.includes(chord) ) { + if( includesModifierKey(chord) ) { + this.setState({...this.state, chord, recordingChord: false, errorMessage: null }) + } else { + const errorMessage = `Keystroke must include ALT, CONTROL, or META keys.` + this.setState({...this.state, errorMessage, recordingChord: false }) + } + } else { + const errorMessage = `${chord.toUpperCase()} key is already assigned to another function.` + this.setState({...this.state, errorMessage, recordingChord: false }) + } + }) + this.setState({...this.state, recordingChord: true }) + } + + return ( + + ) + } + + renderElementField() { + const { elementType, elementName } = this.state + + const onClick = () => { + this.setState({...this.state, elementMenuOptions: { menuGroup: 'mark' } }) + } + + const icon = elementType === 'mark' ? : + const elementButtonLabel = elementName ? { icon } { elementName } : Choose Element + + return ( + + ) + } + + onCloseElementMenu = () => { + this.setState({...this.state, elementMenuOptions: null }) + } + + renderElementMenu() { + const { fairCopyConfig, teiSchema } = this.props + const { elementMenuOptions } = this.state + + if(!elementMenuOptions) return null + + const { menus } = fairCopyConfig + const { elements } = teiSchema + + const onAction = (elementID) => { + this.setState({ ...this.state, elementName: elementID, elementMenuOptions: null }) + } + + return ( + { + this.setState({...this.state, elementMenuOptions: null }) + } } + elementMenuAnchors={this.elementMenuAnchors} + onAction={onAction} + validAction={() => { return true }} + onExited={() => {}} + {...elementMenuOptions} + > + ) + } + + render() { + const { onClose, onSave } = this.props + const { title, chord, elementType, elementName, errorMessage } = this.state + + const onClickSave = () => { + onSave(chord, elementType, elementName) + } + + // TODO: add a place for "keystroke already assigned message" + const chordLabel = chord ? chord.toUpperCase() : 'Unassigned' + + return ( + + { title } + + + + + + Keystroke + Description + + + + + { chordLabel } + Add a {this.renderElementField()} element. + + +
    +
    + { errorMessage && { errorMessage } } + { this.renderChordField() } +
    + + + + + { this.renderElementMenu() } +
    + ) + } + +} + +function includesModifierKey(chord) { + const parts = chord.split('+') + for( const part of parts ) { + if( modifierKeys.includes(part) ) return true + } + return false +} \ No newline at end of file diff --git a/src/components/project-settings-window/KeyBindingsTable.js b/src/components/project-settings-window/KeyBindingsTable.js new file mode 100644 index 00000000..586230e7 --- /dev/null +++ b/src/components/project-settings-window/KeyBindingsTable.js @@ -0,0 +1,106 @@ +import React, { Component } from 'react' +import { Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Paper, Typography } from '@material-ui/core' +import { Tooltip, IconButton, Button } from '@material-ui/core' +import { getElementTypeIcon } from '../../model/TEISchema' +import { teiEditorKeyMap } from '../../model/editor-keybindings' + +import KeyBindingDialog from './KeyBindingDialog' + +export default class KeyBindingsTable extends Component { + + constructor(props) { + super(props) + + this.state = { + selectedAction: null, + selectedKey: null, + keybindingDialog: false + } + } + + render() { + const { fairCopyConfig, teiSchema, onUpdateConfig, readOnly } = this.props + const { selectedAction, selectedKey, keybindingDialog } = this.state + const { keybindings } = fairCopyConfig + const assignedKeys = [ ...Object.keys(keybindings).filter( key => key !== selectedKey ), ...Object.values(teiEditorKeyMap) ] + + const onAddKeybinding = () => { + this.setState({ ...this.state, selectedAction: null, selectedKey: null, keybindingDialog: true }) + } + + const keyRows = [] + for( const chord of Object.keys(keybindings) ) { + const keybinding = keybindings[chord] + const { elementType, elementName } = keybinding + + const onEdit = () => { + this.setState({ ...this.state, selectedKey: chord, selectedAction: keybinding, keybindingDialog: true }) + } + + const onDelete = () => { + delete fairCopyConfig.keybindings[chord] + onUpdateConfig(fairCopyConfig) + } + + const chordLabel = chord ? chord.toUpperCase() : 'Unassigned' + + keyRows.push( + + + {chordLabel} + + + Add a {elementName} element. + + + { !readOnly && } + { !readOnly && } + + + ) + } + + const onClose = () => { + this.setState({ ...this.state, keybindingDialog: false }) + } + + const onSave = (chord, elementType, elementName) => { + keybindings[chord] = { elementType, elementName } + onUpdateConfig(fairCopyConfig) + onClose() + } + + return ( +
    + Hot Keys + Assign hotkeys and review assigned keys. + + + + + Keystroke + Description + { !readOnly && Actions } + + + + { keyRows } + +
    +
    + { !readOnly && } + { keybindingDialog && } +
    + ) + } +} \ No newline at end of file diff --git a/src/components/project-settings-window/ProjectSettingsWindow.js b/src/components/project-settings-window/ProjectSettingsWindow.js index 9e61ff88..ffaece74 100644 --- a/src/components/project-settings-window/ProjectSettingsWindow.js +++ b/src/components/project-settings-window/ProjectSettingsWindow.js @@ -3,6 +3,7 @@ import { Button, Typography, Tabs, Tab } from '@material-ui/core' import GeneralSettings from './GeneralSettings' import SchemaEditor from './SchemaEditor' +import KeyBindingsTable from './KeyBindingsTable' import { canConfigAdmin } from '../../model/permissions' import { getConfigStatus } from '../../model/faircopy-config' import { inlineRingSpinner } from '../common/ring-spinner' @@ -34,9 +35,9 @@ export default class ProjectSettingsWindow extends Component { return (
    - - - {/* */} + + +
    ) @@ -78,9 +79,12 @@ export default class ProjectSettingsWindow extends Component { readOnly={!canEdit} onUpdateConfig={onUpdate} > } - { selectedPage === 'vocabs' &&
    -

    VOCAB EDITOR

    -
    } + { selectedPage === 'keybindings' && }
    ) } diff --git a/src/model/TEISchema.js b/src/model/TEISchema.js index 4862b476..43205b7b 100644 --- a/src/model/TEISchema.js +++ b/src/model/TEISchema.js @@ -294,11 +294,18 @@ export default class TEISchema { const pmTypes = elementTypeToPmTypes[elementType] const menus = pmTypes.map( pmType => pmTypeToMenu[pmType] ).flat() return menus.includes(elementMenu) - } + } +} + +export function getElementIcon(elementID, elements) { + const elementSpec = elements[elementID] + return elementSpec ? `far ${elementSpec.icon}` : null +} - getElementIcon(elementID) { - const elementSpec = this.elements[elementID] - return elementSpec ? `far ${elementSpec.icon}` : null +export function getElementTypeIcon( elementType ) { + if( elementType === 'mark' ) { + return "fas fa-marker" + } else { + return "fas fa-stamp" } - } \ No newline at end of file diff --git a/src/model/editor-keybindings.js b/src/model/editor-keybindings.js new file mode 100644 index 00000000..8234d247 --- /dev/null +++ b/src/model/editor-keybindings.js @@ -0,0 +1,48 @@ +import { createPhraseElement } from "./editor-actions" + +export const teiEditorKeyMap = { + onTogglePalette: '1+Meta', + onOpenMarkMenu: '2+Meta', + onOpenInineMenu: '3+Meta', + eraseSelection: '4+Meta', + undo: 'Meta+z', + redo: 'Meta+Shift+z', + cutSelectedNode: 'Meta+x', + copySelectedNode: 'Meta+v' +} + +export function getHotKeyConfig( teiDocument, teiEditorHandlers ) { + const { projectKeyMap, projectHanders } = getProjectHotKeys( teiDocument ) + + const keyMap = { + ...teiEditorKeyMap, + ...projectKeyMap + } + + const handlers = { + ...teiEditorHandlers, + ...projectHanders + } + + return { keyMap, handlers } +} + +function getProjectHotKeys( teiDocument ) { + const { keybindings } = teiDocument.fairCopyProject.fairCopyConfig + + const projectKeyMap = {}, projectHanders = {} + + let n = 0 + for( const chord of Object.keys(keybindings) ) { + const actionName = `userDefined_${n++}` + const keybinding = keybindings[chord] + const { elementName } = keybinding + + projectKeyMap[actionName] = chord + projectHanders[actionName] = () => { + createPhraseElement( elementName, {}, teiDocument ) + } + } + + return { projectKeyMap, projectHanders } +} \ No newline at end of file diff --git a/src/model/editor-navigation.js b/src/model/editor-navigation.js index 6b878927..200f771e 100644 --- a/src/model/editor-navigation.js +++ b/src/model/editor-navigation.js @@ -1,9 +1,11 @@ -import { cutSelectedNode, copySelectedNode } from "./cut-and-paste" -import { eraseSelection } from "./editor-actions" -import {undo, redo} from "prosemirror-history" + import {TextSelection} from "prosemirror-state" import { getHighlightRanges } from "./highlighter" import { synthNameToElementName, findNoteNode } from "./xml" +import {undo, redo} from "prosemirror-history" +import { cutSelectedNode, copySelectedNode } from "./cut-and-paste" +import { eraseSelection } from "./editor-actions" + const fairCopy = window.fairCopy @@ -130,45 +132,56 @@ export function getEnabledMenus(teiDocument) { } } -export function handleEditorHotKeys(event, teiDocument, onTogglePalette, onOpenElementMenu, clipboardSerializer ) { - const editorView = teiDocument.getActiveView() - const metaKey = ( event.ctrlKey || event.metaKey ) - - const key = event.key.toLowerCase() - - if( metaKey && key === 'x' ) { - cutSelectedNode( teiDocument, clipboardSerializer ) - } - - if( metaKey && key === 'c' ) { - copySelectedNode( teiDocument, clipboardSerializer ) +export function arrowNavToNote( openNotePopup, teiDocument, direction ) { + const { editorView } = teiDocument + const { selection } = editorView.state + + if( selection && selection.node ) { + const { node } = selection + const nodeName = node.type.name + const {teiSchema} = teiDocument.fairCopyProject + + if( teiSchema.elementGroups.asides.includes(nodeName) ) { + const noteID = node.attrs['__id__'] + const { $anchor } = selection + const anchorEl = editorView.nodeDOM($anchor.pos) + openNotePopup(noteID, anchorEl) + } + else { + const {tr, selection} = editorView.state + const {$anchor} = selection + tr.setSelection(TextSelection.create(tr.doc, $anchor.pos + direction)) + editorView.dispatch(tr) + } } +} +export function getEditorCommands( teiDocument, onTogglePalette, onOpenElementMenu, clipboardSerializer ) { + const editorView = teiDocument.getActiveView() const enabledMenus = getEnabledMenus(teiDocument) - - if( metaKey && key === '1' ) { - onTogglePalette() - } - - if( enabledMenus.marks && metaKey && key === '2' ) { - onOpenElementMenu({ menuGroup: 'mark' }) - } - - if( enabledMenus.inline && metaKey && key === '3' ) { - onOpenElementMenu({ menuGroup: 'inline' }) - } - - if( enabledMenus.eraser && metaKey && key === '4' ) { - eraseSelection(teiDocument) + + return { + onTogglePalette: (e) => { + // for some reason, just hitting meta key will activate this handler after it + // has been activated once.. check that we also hit the 1 key. + if( e.key === '1' ) { + onTogglePalette() + } + }, + onOpenMarkMenu: () => { + if(enabledMenus.marks) onOpenElementMenu({ menuGroup: 'mark' }) + }, + onOpenInineMenu: () => { + if(enabledMenus.inline) onOpenElementMenu({ menuGroup: 'inline' }) + }, + eraseSelection: () => { + if(enabledMenus.eraser) eraseSelection(teiDocument) + }, + undo: () => { undo(editorView.state,editorView.dispatch) }, + redo: () => { redo(editorView.state,editorView.dispatch) }, + cutSelectedNode: () => { cutSelectedNode( teiDocument, clipboardSerializer ) }, + copySelectedNode: () => { copySelectedNode( teiDocument, clipboardSerializer ) } } - - // handle undo and redo here so they are available even when focus is not in PM itself - if( metaKey && key === 'z' ) { - undo(editorView.state,editorView.dispatch) - } - if( metaKey && ((event.shiftKey && key === 'z') || key === 'y' )) { - redo(editorView.state,editorView.dispatch) - } } export function getSelectedElements( teiDocument, noteID ) { diff --git a/yarn.lock b/yarn.lock index 28210db7..f15f96fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11260,7 +11260,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8, prop-types@^15.8.1: +prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -11680,6 +11680,13 @@ react-force-graph-2d@^1.13.6: prop-types "^15.8" react-kapsule "^2.2" +react-hotkeys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-2.0.0.tgz#a7719c7340cbba888b0e9184f806a9ec0ac2c53f" + integrity sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q== + dependencies: + prop-types "^15.6.1" + react-input-autosize@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-3.0.0.tgz#6b5898c790d4478d69420b55441fcc31d5c50a85" From 01ea43abcb092e2c8b8c10e1eee369dffdce7236 Mon Sep 17 00:00:00 2001 From: Nick Laiacona Date: Tue, 7 Feb 2023 08:18:58 -0500 Subject: [PATCH 5/8] Prevent errors when migrating keybindings --- .../project-settings-window/KeyBindingsTable.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/project-settings-window/KeyBindingsTable.js b/src/components/project-settings-window/KeyBindingsTable.js index 586230e7..a28863ff 100644 --- a/src/components/project-settings-window/KeyBindingsTable.js +++ b/src/components/project-settings-window/KeyBindingsTable.js @@ -22,14 +22,16 @@ export default class KeyBindingsTable extends Component { const { fairCopyConfig, teiSchema, onUpdateConfig, readOnly } = this.props const { selectedAction, selectedKey, keybindingDialog } = this.state const { keybindings } = fairCopyConfig - const assignedKeys = [ ...Object.keys(keybindings).filter( key => key !== selectedKey ), ...Object.values(teiEditorKeyMap) ] + + const chords = keybindings ? Object.keys(keybindings) : [] + const assignedKeys = [ ...chords.filter( key => key !== selectedKey ), ...Object.values(teiEditorKeyMap) ] const onAddKeybinding = () => { this.setState({ ...this.state, selectedAction: null, selectedKey: null, keybindingDialog: true }) } const keyRows = [] - for( const chord of Object.keys(keybindings) ) { + for( const chord of chords ) { const keybinding = keybindings[chord] const { elementType, elementName } = keybinding @@ -67,7 +69,10 @@ export default class KeyBindingsTable extends Component { } const onSave = (chord, elementType, elementName) => { - keybindings[chord] = { elementType, elementName } + if( !fairCopyConfig.keybindings ) { + fairCopyConfig.keybindings = {} + } + fairCopyConfig.keybindings[chord] = { elementType, elementName } onUpdateConfig(fairCopyConfig) onClose() } From 0a5c68f0397f4a00d0626355a8c7777d1827a896 Mon Sep 17 00:00:00 2001 From: Nick Laiacona Date: Tue, 7 Feb 2023 08:20:10 -0500 Subject: [PATCH 6/8] Add Attribute Drawer Pin (#503) --- public/css/ParameterDrawer.css | 7 +++- .../main-window/tei-editor/ParameterDrawer.js | 33 ++++++++++++++----- .../main-window/tei-editor/TEIEditor.js | 14 ++++++-- src/model/editor-keybindings.js | 22 +++++++------ 4 files changed, 53 insertions(+), 23 deletions(-) diff --git a/public/css/ParameterDrawer.css b/public/css/ParameterDrawer.css index 34672a36..eba56073 100644 --- a/public/css/ParameterDrawer.css +++ b/public/css/ParameterDrawer.css @@ -123,4 +123,9 @@ #ParameterDrawer .edit-note-button { margin-top: 30px; margin-right: 20px; -} \ No newline at end of file +} + +#ParameterDrawer .drawer-pin { + float: right; + margin-right: 10px; +} diff --git a/src/components/main-window/tei-editor/ParameterDrawer.js b/src/components/main-window/tei-editor/ParameterDrawer.js index c0e72bae..eeba92b2 100644 --- a/src/components/main-window/tei-editor/ParameterDrawer.js +++ b/src/components/main-window/tei-editor/ParameterDrawer.js @@ -1,6 +1,6 @@ import React, { Component } from 'react'; import { Button, Popper, Paper, ClickAwayListener, IconButton } from '@material-ui/core' -import { Card, CardContent, CardActions, CardHeader } from '@material-ui/core' +import { Card, CardContent, CardActions, CardHeader, Tooltip } from '@material-ui/core' import Typography from '@material-ui/core/Typography' import {Node} from 'prosemirror-model' @@ -430,11 +430,28 @@ export default class ParameterDrawer extends Component { ) } + renderDrawerPin() { + const { drawerPinned, onDrawerPinToggle } = this.props + const pinIcon = drawerPinned ? 'fas fa-thumbtack' : 'far fa-thumbtack' + + return ( + + + + + + ) + } + render() { - const { teiDocument, width, height, onRef } = this.props + const { teiDocument, width, height, drawerPinned, onRef } = this.props const elements = teiDocument.selectedElements - if( elements.length === 0 ) return null + if( !drawerPinned && elements.length === 0 ) return null const elementEls = [] let count = 0 @@ -448,14 +465,12 @@ export default class ParameterDrawer extends Component { return (
    + { this.renderDrawerPin() } {headerMessage}
    - { elementEls.length > 0 ? -
    - { elementEls } -
    - : null - } +
    + { (drawerPinned || elementEls.length > 0) && elementEls } +
    { this.renderDialogs() }
    ) diff --git a/src/components/main-window/tei-editor/TEIEditor.js b/src/components/main-window/tei-editor/TEIEditor.js index 6176f825..9fccfd0f 100644 --- a/src/components/main-window/tei-editor/TEIEditor.js +++ b/src/components/main-window/tei-editor/TEIEditor.js @@ -32,6 +32,7 @@ export default class TEIEditor extends Component { notePopupAnchorEl: null, elementMenuOptions: null, paletteWindowOpen: false, + drawerPinned: false, currentSubmenuID: 0 } this.drawerRef = null @@ -215,6 +216,11 @@ export default class TEIEditor extends Component { this.setState({...this.state, paletteWindowOpen: !paletteWindowOpen}) } + onDrawerPinToggle = () => { + const { drawerPinned } = this.state + this.setState({...this.state, drawerPinned: !drawerPinned}) + } + openNotePopup = (noteID, notePopupAnchorEl) => { this.setState({...this.state, noteID, notePopupAnchorEl }) } @@ -272,7 +278,7 @@ export default class TEIEditor extends Component { render() { const { teiDocument, parentResource, hidden, onSave, onDragElement, onAlertMessage, onEditResource, onProjectSettings, onResourceAction, resourceEntry, leftPaneWidth, currentView } = this.props - const { noteID, notePopupAnchorEl, elementMenuOptions, currentSubmenuID, paletteWindowOpen } = this.state + const { noteID, notePopupAnchorEl, elementMenuOptions, currentSubmenuID, drawerPinned, paletteWindowOpen } = this.state const { fairCopyProject } = teiDocument const { isLoggedIn, configLastAction, userID, permissions, remote } = fairCopyProject const readOnly = !teiDocument.isEditable() @@ -300,9 +306,9 @@ export default class TEIEditor extends Component { } const { selectedElements } = teiDocument - const drawerHeight = selectedElements.length > 0 ? 300 : 50 //335 + const drawerHeight = drawerPinned || selectedElements.length > 0 ? 300 : 50 const drawerWidthCSS = `calc(100vw - 30px - ${leftPaneWidth}px)` - const editorHeight = selectedElements.length > 0 ? 530 : 180 + const editorHeight = drawerPinned || selectedElements.length > 0 ? 530 : 180 const editorHeightCSS = `calc(100% - ${editorHeight}px)` const editorWidthCSS = `calc(100vw - 10px - ${leftPaneWidth}px)` @@ -371,6 +377,8 @@ export default class TEIEditor extends Component { { !hidden && { this.drawerRef = el}} + drawerPinned={drawerPinned} + onDrawerPinToggle={this.onDrawerPinToggle} noteID={noteID} height={drawerHeight} width={drawerWidthCSS} diff --git a/src/model/editor-keybindings.js b/src/model/editor-keybindings.js index 8234d247..628ea0e7 100644 --- a/src/model/editor-keybindings.js +++ b/src/model/editor-keybindings.js @@ -32,16 +32,18 @@ function getProjectHotKeys( teiDocument ) { const projectKeyMap = {}, projectHanders = {} - let n = 0 - for( const chord of Object.keys(keybindings) ) { - const actionName = `userDefined_${n++}` - const keybinding = keybindings[chord] - const { elementName } = keybinding - - projectKeyMap[actionName] = chord - projectHanders[actionName] = () => { - createPhraseElement( elementName, {}, teiDocument ) - } + if( keybindings ) { + let n = 0 + for( const chord of Object.keys(keybindings) ) { + const actionName = `userDefined_${n++}` + const keybinding = keybindings[chord] + const { elementName } = keybinding + + projectKeyMap[actionName] = chord + projectHanders[actionName] = () => { + createPhraseElement( elementName, {}, teiDocument ) + } + } } return { projectKeyMap, projectHanders } From 9ff42db10ae992b9e00808dc8f5cf0d11712f7bd Mon Sep 17 00:00:00 2001 From: Nick Laiacona Date: Tue, 7 Feb 2023 08:22:43 -0500 Subject: [PATCH 7/8] 1.1.6-dev.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 56488b59..613595aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "faircopy", - "version": "1.1.6-dev.1", + "version": "1.1.6-dev.2", "description": "A word processor for the humanities scholar.", "main": "public/electron.js", "private": true, From aad4a10bba8485be995d4b918cbf4b91b1d32fcf Mon Sep 17 00:00:00 2001 From: Nick Laiacona Date: Wed, 8 Feb 2023 08:28:24 -0500 Subject: [PATCH 8/8] v1.1.6 --- package.json | 6 +++--- public/main-process/config/dist-config.json | 2 +- public/main-process/release-notes/latest.md | 11 +++++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 613595aa..dd9a246f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "faircopy", - "version": "1.1.6-dev.2", + "version": "1.1.6", "description": "A word processor for the humanities scholar.", "main": "public/electron.js", "private": true, @@ -126,8 +126,8 @@ "publish": { "provider": "keygen", "account": "8a8d3d6a-ab09-4f51-aea5-090bfd025dd8", - "product": "b2bfc67b-26bf-4407-b3d9-d7ad94d7f225", - "channel": "dev" + "product": "1e330e29-f0b4-4942-b813-0c78614e3abb", + "channel": "stable" } }, "postinstall": "electron-builder install-app-deps", diff --git a/public/main-process/config/dist-config.json b/public/main-process/config/dist-config.json index 04b67e22..8df234ac 100644 --- a/public/main-process/config/dist-config.json +++ b/public/main-process/config/dist-config.json @@ -1,5 +1,5 @@ { - "devMode": true, + "devMode": false, "devURL": "https://faircopy-activate-2-staging.herokuapp.com", "prodURL": "https://faircopyeditor.com" } \ No newline at end of file diff --git a/public/main-process/release-notes/latest.md b/public/main-process/release-notes/latest.md index 779a484b..f6ccba4b 100644 --- a/public/main-process/release-notes/latest.md +++ b/public/main-process/release-notes/latest.md @@ -2,6 +2,17 @@ The release notes list the improvements and bug fixes included in each new version of the software. +## Version 1.1.6 + +### Improvements + +* Users can now create hotkeys for their favorite Mark elements. Hotkeys must use at least one of: alt, control, meta, or option keys. When used in a remote project, the user must have project config privs to edit the hotkeys, which will be shared by all members of the project. +* User can now pin the attribute drawer in the open position, to prevent the editor from hopping around as they move between elements. + +### Bug Fixes + +* A number of issues have been addressed around log in to remote projects, include server side session time outs. + ## Version 1.1.4 ### Improvements