diff --git a/react-electron-poc/.eslintrc.js b/react-electron-poc/.eslintrc.js index 2eed088..50b023f 100644 --- a/react-electron-poc/.eslintrc.js +++ b/react-electron-poc/.eslintrc.js @@ -14,6 +14,7 @@ module.exports = { 'prettier/prettier': ['warn', { tabWidth: 4, trailingComma: 'all' }], 'no-console': 'off', 'react/require-default-props': 'off', + 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], }, parserOptions: { ecmaVersion: 2020, diff --git a/react-electron-poc/package-lock.json b/react-electron-poc/package-lock.json index 7012faa..c0d3eb1 100644 --- a/react-electron-poc/package-lock.json +++ b/react-electron-poc/package-lock.json @@ -14,7 +14,9 @@ "react": "^18.2.0", "react-contenteditable": "^3.3.6", "react-dom": "^18.2.0", - "react-router-dom": "^6.3.0" + "react-router-dom": "^6.3.0", + "slate": "^0.82.1", + "slate-react": "^0.82.2" }, "devDependencies": { "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", @@ -2076,6 +2078,11 @@ "@types/node": "*" } }, + "node_modules/@types/is-hotkey": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@types/is-hotkey/-/is-hotkey-0.1.7.tgz", + "integrity": "sha512-yB5C7zcOM7idwYZZ1wKQ3pTfjA9BbvFqRWvKB46GFddxnJtHwi/b9y84ykQtxQPg5qhdpg4Q/kWU3EGoCTmLzQ==" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -2181,6 +2188,11 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.14.185", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.185.tgz", + "integrity": "sha512-evMDG1bC4rgQg4ku9tKpuMh5iBNEwNa3tf9zRHdP1qlv+1WUg44xat4IxCE14gIpZRGUUWAx2VhItCZc25NfMA==" + }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -4648,6 +4660,11 @@ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "dev": true }, + "node_modules/compute-scroll-into-view": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz", + "integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5497,6 +5514,18 @@ "node": ">=8" } }, + "node_modules/direction": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", + "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dmg-builder": { "version": "23.5.0", "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-23.5.0.tgz", @@ -9148,6 +9177,15 @@ "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", "dev": true }, + "node_modules/immer": { + "version": "9.0.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.15.tgz", + "integrity": "sha512-2eB/sswms9AEUSkOm4SbV5Y7Vmt/bKRwByd52jfLkW4OLYeaTP3EEiJ9agqU0O/tq6Dk62Zfj+TJSqfm1rLVGQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz", @@ -9446,6 +9484,11 @@ "node": ">=0.10.0" } }, + "node_modules/is-hotkey": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.8.tgz", + "integrity": "sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ==" + }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -11347,8 +11390,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.difference": { "version": "4.5.0", @@ -14140,6 +14182,14 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/scroll-into-view-if-needed": { + "version": "2.2.29", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.29.tgz", + "integrity": "sha512-hxpAR6AN+Gh53AdAimHM6C8oTN1ppwVZITihix+WqalywBeFcQ6LdQP5ABNl26nX8GTEL7VT+b8lKpdqq65wXg==", + "dependencies": { + "compute-scroll-into-view": "^1.0.17" + } + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -14487,6 +14537,52 @@ "node": ">=8" } }, + "node_modules/slate": { + "version": "0.82.1", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.82.1.tgz", + "integrity": "sha512-3mdRdq7U3jSEoyFrGvbeb28hgrvrr4NdFCtJX+IjaNvSFozY0VZd/CGHF0zf/JDx7aEov864xd5uj0HQxxEWTQ==", + "dependencies": { + "immer": "^9.0.6", + "is-plain-object": "^5.0.0", + "tiny-warning": "^1.0.3" + } + }, + "node_modules/slate-react": { + "version": "0.82.2", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.82.2.tgz", + "integrity": "sha512-lNmSqqNOKQJG1i3Wx4MkggWr6dLhQ43n4exPf5NLmKVBHPBuQSRNaWhAAKm6HOEC36Q6xBfkVONIQWi3GSyIEQ==", + "dependencies": { + "@types/is-hotkey": "^0.1.1", + "@types/lodash": "^4.14.149", + "direction": "^1.0.3", + "is-hotkey": "^0.1.6", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.4", + "scroll-into-view-if-needed": "^2.2.20", + "tiny-invariant": "1.0.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "slate": ">=0.65.3" + } + }, + "node_modules/slate-react/node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/slate/node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/slice-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", @@ -15192,6 +15288,16 @@ "globrex": "^0.1.2" } }, + "node_modules/tiny-invariant": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.6.tgz", + "integrity": "sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA==" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "node_modules/tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", @@ -18170,6 +18276,11 @@ "@types/node": "*" } }, + "@types/is-hotkey": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@types/is-hotkey/-/is-hotkey-0.1.7.tgz", + "integrity": "sha512-yB5C7zcOM7idwYZZ1wKQ3pTfjA9BbvFqRWvKB46GFddxnJtHwi/b9y84ykQtxQPg5qhdpg4Q/kWU3EGoCTmLzQ==" + }, "@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -18268,6 +18379,11 @@ "@types/node": "*" } }, + "@types/lodash": { + "version": "4.14.185", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.185.tgz", + "integrity": "sha512-evMDG1bC4rgQg4ku9tKpuMh5iBNEwNa3tf9zRHdP1qlv+1WUg44xat4IxCE14gIpZRGUUWAx2VhItCZc25NfMA==" + }, "@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -20196,6 +20312,11 @@ } } }, + "compute-scroll-into-view": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz", + "integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -20821,6 +20942,11 @@ "path-type": "^4.0.0" } }, + "direction": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", + "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==" + }, "dmg-builder": { "version": "23.5.0", "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-23.5.0.tgz", @@ -23557,6 +23683,11 @@ "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", "dev": true }, + "immer": { + "version": "9.0.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.15.tgz", + "integrity": "sha512-2eB/sswms9AEUSkOm4SbV5Y7Vmt/bKRwByd52jfLkW4OLYeaTP3EEiJ9agqU0O/tq6Dk62Zfj+TJSqfm1rLVGQ==" + }, "immutable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz", @@ -23770,6 +23901,11 @@ "is-extglob": "^2.1.1" } }, + "is-hotkey": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.8.tgz", + "integrity": "sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ==" + }, "is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -25186,8 +25322,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.difference": { "version": "4.5.0", @@ -27207,6 +27342,14 @@ "ajv-keywords": "^3.5.2" } }, + "scroll-into-view-if-needed": { + "version": "2.2.29", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.29.tgz", + "integrity": "sha512-hxpAR6AN+Gh53AdAimHM6C8oTN1ppwVZITihix+WqalywBeFcQ6LdQP5ABNl26nX8GTEL7VT+b8lKpdqq65wXg==", + "requires": { + "compute-scroll-into-view": "^1.0.17" + } + }, "select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -27497,6 +27640,45 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true }, + "slate": { + "version": "0.82.1", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.82.1.tgz", + "integrity": "sha512-3mdRdq7U3jSEoyFrGvbeb28hgrvrr4NdFCtJX+IjaNvSFozY0VZd/CGHF0zf/JDx7aEov864xd5uj0HQxxEWTQ==", + "requires": { + "immer": "^9.0.6", + "is-plain-object": "^5.0.0", + "tiny-warning": "^1.0.3" + }, + "dependencies": { + "is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" + } + } + }, + "slate-react": { + "version": "0.82.2", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.82.2.tgz", + "integrity": "sha512-lNmSqqNOKQJG1i3Wx4MkggWr6dLhQ43n4exPf5NLmKVBHPBuQSRNaWhAAKm6HOEC36Q6xBfkVONIQWi3GSyIEQ==", + "requires": { + "@types/is-hotkey": "^0.1.1", + "@types/lodash": "^4.14.149", + "direction": "^1.0.3", + "is-hotkey": "^0.1.6", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.4", + "scroll-into-view-if-needed": "^2.2.20", + "tiny-invariant": "1.0.6" + }, + "dependencies": { + "is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" + } + } + }, "slice-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", @@ -28036,6 +28218,16 @@ "globrex": "^0.1.2" } }, + "tiny-invariant": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.6.tgz", + "integrity": "sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA==" + }, + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", diff --git a/react-electron-poc/package.json b/react-electron-poc/package.json index 92be70b..fd444d1 100644 --- a/react-electron-poc/package.json +++ b/react-electron-poc/package.json @@ -117,7 +117,9 @@ "react": "^18.2.0", "react-contenteditable": "^3.3.6", "react-dom": "^18.2.0", - "react-router-dom": "^6.3.0" + "react-router-dom": "^6.3.0", + "slate": "^0.82.1", + "slate-react": "^0.82.2" }, "devDependencies": { "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", diff --git a/react-electron-poc/src/main/main.ts b/react-electron-poc/src/main/main.ts index d6bd7cb..f859451 100644 --- a/react-electron-poc/src/main/main.ts +++ b/react-electron-poc/src/main/main.ts @@ -169,6 +169,9 @@ const getScriptureDelay = 75; /** Simulating how long it may take Paratext to serve the resource info */ const getResourceInfoDelay = 20; +/** Regex for test Scripture file name bookNum-chapterNum.fileExtension */ +const regexpScrFileName = /(\d+)-(\d+)\.(.+)/; + /** * Get the Scripture for a certain project for a whole book. * The json files are Slate JSON files @@ -189,20 +192,59 @@ async function handleGetScriptureBook( bookNum: number, ): Promise { // TODO: If we want to implement this, parse file and split out into actual chapters - try { - return await getFilesText( - [`testScripture/${shortName}/${bookNum}.${fileExtension}`], - getScriptureDelay, - ).then((filesContents) => - filesContents.map((fileContents, ind) => ({ - chapter: ind, - contents: fileContents, - })), + return delayPromise((resolve, reject) => { + fs.readdir( + getAssetPath(`testScripture/${shortName}`), + { + withFileTypes: true, + }, + async (err, dirents) => { + if (err) reject(`No path data for ${shortName} ${bookNum}`); + else { + try { + // Get all Scripture files + const scrFilePaths = dirents + .filter((dirent) => { + if (dirent.isDirectory()) return false; + + const scrFileNameMatch = + dirent.name.match(regexpScrFileName); + return ( + scrFileNameMatch && + scrFileNameMatch.length >= 4 && + scrFileNameMatch[3] === fileExtension + ); + }) + .map( + (dirent) => + `testScripture/${shortName}/${dirent.name}`, + ); + const filesContents = await getFilesText( + scrFilePaths, + 0, + ); + resolve( + filesContents.map( + (fileContents, i) => + ({ + chapter: parseInt( + scrFilePaths[i].match( + regexpScrFileName, + )[2], + 10, + ), + contents: fileContents, + } as ScriptureChapter), + ), + ); + } catch (e) { + console.log(e); + reject(`No data for ${shortName} ${bookNum}`); + } + } + }, ); - } catch (e) { - console.log(e); - throw new Error(`No data for ${shortName} ${bookNum}`); - } + }, getScriptureDelay); } /** diff --git a/react-electron-poc/src/renderer/components/ScrRefSelector.tsx b/react-electron-poc/src/renderer/components/ScrRefSelector.tsx index dd39949..cc6d581 100644 --- a/react-electron-poc/src/renderer/components/ScrRefSelector.tsx +++ b/react-electron-poc/src/renderer/components/ScrRefSelector.tsx @@ -24,7 +24,7 @@ export default ({ scrRef, handleSubmit }: ScrRefSelectorProps) => { const handleChange = useCallback( (e: React.ChangeEvent) => { - setCurrentRefText(e.currentTarget.value); + setCurrentRefText(e.target.value); }, [], ); diff --git a/react-electron-poc/src/renderer/components/layout/Layout.css b/react-electron-poc/src/renderer/components/layout/Layout.css index 53ef03e..7b836a7 100644 --- a/react-electron-poc/src/renderer/components/layout/Layout.css +++ b/react-electron-poc/src/renderer/components/layout/Layout.css @@ -3,12 +3,38 @@ body { } #root { - width: 100vw; height: 100vh; } .layout { height: 100%; + display: flex; + flex-direction: column; + background-color: #1c1c2a; +} + +.layout .layout-bar { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.layout .layout-bar .layout-checkbox { + background-color: #1c1c2a; + color: #505070; + border-radius: 5px; + border: 1px solid #505070; + padding: 3px; + margin: 4px; + flex-shrink: 0; +} + +.layout .layout-dock { + flex-grow: 1; + /* Workaround for restoring from maximized leaving the dockview at maximized height. + See https://github.com/mathuo/dockview/issues/158#issuecomment-1260007967 for more info */ + overflow: hidden; } .groupview.active-group > .tabs-and-actions-container { diff --git a/react-electron-poc/src/renderer/components/layout/Layout.tsx b/react-electron-poc/src/renderer/components/layout/Layout.tsx index 3d49ff4..cc3f279 100644 --- a/react-electron-poc/src/renderer/components/layout/Layout.tsx +++ b/react-electron-poc/src/renderer/components/layout/Layout.tsx @@ -7,26 +7,44 @@ import { getAllResourceInfo, getResourceInfo, } from '@services/ScriptureService'; -import { ScriptureTextPanelProps } from '@components/panels/TextPanels/ScriptureTextPanel'; +import { ScriptureTextPanelHtmlProps } from '@components/panels/TextPanels/ScriptureTextPanelHtml'; import { ScriptureReference } from '@shared/data/ScriptureTypes'; import ScrRefSelector from '@components/ScrRefSelector'; import { PanelManager } from '@components/panels/PanelManager'; +import { getSetting, setSetting } from '@services/SettingsService'; + +/** Key for saving scrRef setting */ +const scrRefSettingKey = 'scrRef'; +/** Key for saving browseBook setting */ +const browseBookSettingKey = 'browseBook'; const Layout = () => { const panelManager = useRef(undefined); - const [scrRef, setScrRef] = useState({ - book: 19, - chapter: 119, - verse: 1, - }); - + const [scrRef, setScrRef] = useState( + getSetting(scrRefSettingKey) || { + book: 19, + chapter: 119, + verse: 1, + }, + ); const updateScrRef = useCallback((newScrRef: ScriptureReference) => { setScrRef(newScrRef); + setSetting(scrRefSettingKey, newScrRef); panelManager.current?.updateScrRef(newScrRef); }, []); + const [browseBook, setBrowseBook] = useState( + getSetting(browseBookSettingKey) || false, + ); + const updateBrowseBook = useCallback((newBrowseBook: boolean) => { + setBrowseBook(newBrowseBook); + setSetting(browseBookSettingKey, newBrowseBook); + + panelManager.current?.updateBrowseBook(newBrowseBook); + }, []); + const onReady = useCallback( (event: DockviewReadyEvent) => { // Test resource info api @@ -69,20 +87,24 @@ const Layout = () => { }, ); */ const csbPanel = panelManager.current.addPanel( - 'ScriptureTextPanel', + 'ScriptureTextPanelHtml', { shortName: 'CSB', editable: false, ...scrRef, - } as ScriptureTextPanelProps, + updateScrRef, + browseBook, + } as ScriptureTextPanelHtmlProps, ); const ohebPanel = panelManager.current.addPanel( - 'ScriptureTextPanel', + 'ScriptureTextPanelHtml', { shortName: 'OHEB', editable: false, ...scrRef, - } as ScriptureTextPanelProps, + updateScrRef, + browseBook, + } as ScriptureTextPanelHtmlProps, { position: { direction: 'right', @@ -91,12 +113,14 @@ const Layout = () => { }, ); panelManager.current.addPanel( - 'ScriptureTextPanel', + 'ScriptureTextPanelSlate', { shortName: 'zzz6', editable: true, ...scrRef, - } as ScriptureTextPanelProps, + updateScrRef, + browseBook, + } as ScriptureTextPanelHtmlProps, { position: { direction: 'below', @@ -105,12 +129,14 @@ const Layout = () => { }, ); panelManager.current.addPanel( - 'ScriptureTextPanel', + 'ScriptureTextPanelHtml', { shortName: 'NIV84', editable: false, ...scrRef, - } as ScriptureTextPanelProps, + updateScrRef, + browseBook, + } as ScriptureTextPanelHtmlProps, { position: { direction: 'below', @@ -118,13 +144,15 @@ const Layout = () => { }, }, ); - panelManager.current.addPanel( - 'ScriptureTextPanel', + const zzz1Panel = panelManager.current.addPanel( + 'ScriptureTextPanelHtml', { shortName: 'zzz1', editable: true, ...scrRef, - } as ScriptureTextPanelProps, + updateScrRef, + browseBook, + } as ScriptureTextPanelHtmlProps, { position: { direction: 'below', @@ -132,21 +160,49 @@ const Layout = () => { }, }, ); + panelManager.current.addPanel( + 'ScriptureTextPanelHtml', + { + shortName: 'zzz6', + editable: true, + ...scrRef, + updateScrRef, + browseBook, + } as ScriptureTextPanelHtmlProps, + { + position: { + direction: 'within', + referencePanel: zzz1Panel.id, + }, + }, + ); }, - [scrRef], + [scrRef, updateScrRef], ); return ( - <> - -
+
+
+ + + Edit whole book + + updateBrowseBook(event.target.checked) + } + /> + +
+
- +
); }; export default Layout; diff --git a/react-electron-poc/src/renderer/components/panels/PanelManager.ts b/react-electron-poc/src/renderer/components/panels/PanelManager.ts index 4485221..aa22406 100644 --- a/react-electron-poc/src/renderer/components/panels/PanelManager.ts +++ b/react-electron-poc/src/renderer/components/panels/PanelManager.ts @@ -3,7 +3,7 @@ import { getTextFromScrRef } from '@util/ScriptureUtil'; import { newGuid } from '@util/Util'; import { DockviewReadyEvent, AddPanelOptions } from 'dockview'; import { PanelType } from './Panels'; -import { ScriptureTextPanelProps } from './TextPanels/ScriptureTextPanel'; +import { ScriptureTextPanelHOCProps } from './TextPanels/ScriptureTextPanelHOC'; export interface PanelInfo { id: string; @@ -30,13 +30,15 @@ export class PanelManager { throw new Error('generatePanelTitle: panelInfo undefined!'); if (panelInfo.title || panelInfo.title === '') return panelInfo.title; - if (panelInfo.type === 'ScriptureTextPanel') { - const scrPanelProps = panelProps as ScriptureTextPanelProps; + if (panelInfo.type.startsWith('ScriptureTextPanel')) { + const scrPanelProps = panelProps as ScriptureTextPanelHOCProps; return `${scrPanelProps.shortName}: ${getTextFromScrRef({ book: scrPanelProps.book, chapter: scrPanelProps.chapter, verse: -1, - })}${scrPanelProps.editable ? ' Editable' : ''}`; + })}${scrPanelProps.editable ? ' Editable' : ''}${ + panelInfo.type === 'ScriptureTextPanelSlate' ? ' Slate' : '' + }`; } return panelInfo.type; } @@ -102,4 +104,22 @@ export class PanelManager { }); }); } + + updateBrowseBook(newBrowseBook: boolean): void { + this.dockview.api.panels.forEach((panel) => { + const panelPropsUpdated = { + ...panel.params, + browseBook: newBrowseBook, + }; + panel.update({ + params: { + params: panelPropsUpdated, + title: PanelManager.generatePanelTitle( + this.panelsInfo.get(panel.id), + panelPropsUpdated, + ), + }, + }); + }); + } } diff --git a/react-electron-poc/src/renderer/components/panels/Panels.ts b/react-electron-poc/src/renderer/components/panels/Panels.ts index d1d2868..20484d4 100644 --- a/react-electron-poc/src/renderer/components/panels/Panels.ts +++ b/react-electron-poc/src/renderer/components/panels/Panels.ts @@ -4,7 +4,8 @@ import { Erb } from './Erb/Erb'; import { TextPanel } from './TextPanels/TextPanel'; import { HtmlTextPanel } from './TextPanels/HtmlTextPanel'; import { EditableHtmlTextPanel } from './TextPanels/EditableHtmlTextPanel'; -import { ScriptureTextPanel } from './TextPanels/ScriptureTextPanel'; +import { ScriptureTextPanelHtml } from './TextPanels/ScriptureTextPanelHtml'; +import { ScriptureTextPanelSlate } from './TextPanels/ScriptureTextPanelSlate'; // TODO: Consider doing something a bit different with this https://stackoverflow.com/questions/29722270/is-it-possible-to-import-modules-from-all-files-in-a-directory-using-a-wildcard /** All available panels for use in dockviews */ @@ -13,7 +14,8 @@ export const Panels = { TextPanel, HtmlTextPanel, EditableHtmlTextPanel, - ScriptureTextPanel, + ScriptureTextPanelHtml, + ScriptureTextPanelSlate, }; export default Panels; diff --git a/react-electron-poc/src/renderer/components/panels/TextPanels/ScriptureTextPanel.tsx b/react-electron-poc/src/renderer/components/panels/TextPanels/ScriptureTextPanel.tsx deleted file mode 100644 index 8e26d51..0000000 --- a/react-electron-poc/src/renderer/components/panels/TextPanels/ScriptureTextPanel.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { - getScriptureStyle, - getScriptureHtml, -} from '@services/ScriptureService'; -import { - ResourceInfo, - ScriptureChapter, - ScriptureReference, -} from '@shared/data/ScriptureTypes'; -import { getTextFromScrRef } from '@util/ScriptureUtil'; -import { isValidValue } from '@util/Util'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import ContentEditable, { ContentEditableEvent } from 'react-contenteditable'; -import usePromise from 'renderer/hooks/usePromise'; -import useStyle from 'renderer/hooks/useStyle'; -import './TextPanel.css'; - -export interface ScriptureTextPanelProps - extends ScriptureReference, - ResourceInfo {} - -export const ScriptureTextPanel = ({ - shortName, - editable, - book, - chapter, - verse, -}: ScriptureTextPanelProps) => { - useStyle( - useCallback(async () => { - // TODO: Fix RTL scripture style sheets - if (!shortName) return undefined; - const style = await getScriptureStyle(shortName); - return shortName !== 'OHEB' && shortName !== 'zzz1' - ? style - : undefined; - }, [shortName]), - ); - - const [scrChapters] = usePromise( - useCallback(async () => { - if (!shortName || !isValidValue(book) || !isValidValue(chapter)) - return null; - return getScriptureHtml(shortName, book, chapter); - }, [shortName, book, chapter]), - useState([ - { - chapter: -1, - contents: `Loading ${shortName} ${getTextFromScrRef({ - book, - chapter, - verse: -1, - })}...`, - }, - ])[0], - ); - - const editableScrChapters = useRef([ - { - chapter: -1, - contents: `Loading ${shortName} ${getTextFromScrRef({ - book, - chapter, - verse: -1, - })}...`, - }, - ]); - const [, setForceRefresh] = useState(0); - const forceRefresh = useCallback( - () => setForceRefresh((value) => value + 1), - [setForceRefresh], - ); - - useEffect(() => { - editableScrChapters.current = scrChapters; - forceRefresh(); - }, [scrChapters, forceRefresh]); - - const handleChange = (evt: ContentEditableEvent, editedChapter: number) => { - const editedChapterInd = editableScrChapters.current.findIndex( - (scrChapter) => scrChapter.chapter === editedChapter, - ); - editableScrChapters.current[editedChapterInd] = { - ...editableScrChapters.current[editedChapterInd], - contents: evt.target.value, - }; - }; - - return ( -
- {editable - ? editableScrChapters.current.map((scrChapter) => ( - handleChange(e, scrChapter.chapter)} - /> - )) - : scrChapters.map((scrChapter) => ( -
- ))} -
- ); -}; diff --git a/react-electron-poc/src/renderer/components/panels/TextPanels/ScriptureTextPanelHOC.tsx b/react-electron-poc/src/renderer/components/panels/TextPanels/ScriptureTextPanelHOC.tsx new file mode 100644 index 0000000..22e4e1d --- /dev/null +++ b/react-electron-poc/src/renderer/components/panels/TextPanels/ScriptureTextPanelHOC.tsx @@ -0,0 +1,84 @@ +import { getScriptureStyle } from '@services/ScriptureService'; +import { + ResourceInfo, + ScriptureChapter, + ScriptureReference, +} from '@shared/data/ScriptureTypes'; +import { getTextFromScrRef } from '@util/ScriptureUtil'; +import { isValidValue } from '@util/Util'; +import { + ComponentType, + memo, + PropsWithChildren, + useCallback, + useState, +} from 'react'; +import usePromise from 'renderer/hooks/usePromise'; +import useStyle from 'renderer/hooks/useStyle'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ScriptureTextPanelHOCProps + extends ScriptureReference, + ResourceInfo, + PropsWithChildren { + scrChapters: ScriptureChapter[]; + updateScrRef: (newScrRef: ScriptureReference) => void; + browseBook?: boolean; +} + +export function ScriptureTextPanelHOC( + WrappedComponent: ComponentType, + getScrChapter: ( + shortName: string, + bookNum: number, + chapter?: number, + ) => Promise, +) { + return memo(function ScriptureTextPanel(props: T) { + const { shortName, book, chapter, children, browseBook } = props; + + // Pull in the project's stylesheet + useStyle( + useCallback(async () => { + // TODO: Fix RTL scripture style sheets + if (!shortName) return undefined; + const style = await getScriptureStyle(shortName); + return shortName !== 'OHEB' && shortName !== 'zzz1' + ? style + : undefined; + }, [shortName]), + ); + + const getMyScrBook = useCallback(async () => { + if (!shortName || !isValidValue(book)) return null; + return getScrChapter(shortName, book, -1); + }, [shortName, book]); + const getMyScrChapter = useCallback(async () => { + if (!shortName || !isValidValue(book) || !isValidValue(chapter)) + return null; + return getScrChapter(shortName, book, chapter); + }, [shortName, book, chapter]); + + // Get the project's contents + const [scrChapters] = usePromise( + browseBook ? getMyScrBook : getMyScrChapter, + useState([ + { + chapter: -1, + contents: `Loading ${shortName} ${getTextFromScrRef({ + book, + chapter, + verse: -1, + })}...`, + }, + ])[0], + ); + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {children} + + ); + }); +} diff --git a/react-electron-poc/src/renderer/components/panels/TextPanels/ScriptureTextPanelHtml.tsx b/react-electron-poc/src/renderer/components/panels/TextPanels/ScriptureTextPanelHtml.tsx new file mode 100644 index 0000000..d54205c --- /dev/null +++ b/react-electron-poc/src/renderer/components/panels/TextPanels/ScriptureTextPanelHtml.tsx @@ -0,0 +1,258 @@ +import { getScriptureHtml } from '@services/ScriptureService'; +import { + ScriptureChapter, + ScriptureChapterString, +} from '@shared/data/ScriptureTypes'; +import { + getTextFromScrRef, + parseChapter, + parseVerse, +} from '@util/ScriptureUtil'; +import { isValidValue } from '@util/Util'; +import { EventHandler, useCallback, useEffect, useRef, useState } from 'react'; +import ContentEditable, { ContentEditableEvent } from 'react-contenteditable'; +import { + ScriptureTextPanelHOC, + ScriptureTextPanelHOCProps, +} from './ScriptureTextPanelHOC'; +import './TextPanel.css'; + +/** Regex for parsing cvCHAPTER_VERSE ids in the html supplied by Paratext */ +const regexpChapterVerseId = /cv(\d+)_(\d+)/; + +export interface ScriptureTextPanelHtmlProps + extends ScriptureTextPanelHOCProps { + scrChapters: ScriptureChapterString[]; +} + +/** The function to use to get the Scripture chapter content to display */ +const getScrChapter = getScriptureHtml; + +export const ScriptureTextPanelHtml = ScriptureTextPanelHOC( + ({ + shortName, + editable, + book, + chapter, + verse, + scrChapters, + updateScrRef, + }: ScriptureTextPanelHtmlProps) => { + /** Ref for the top-level editor div */ + const editorRef = useRef(null); + + // Make a ref for the Scripture that works with react-content-editable + const editableScrChapters = useRef([ + { + chapter: -1, + contents: 'Loading', + }, + ]); + // Need to force refresh with react-content-editable + const [, setForceRefresh] = useState(0); + const forceRefresh = useCallback( + () => setForceRefresh((value) => value + 1), + [setForceRefresh], + ); + + // When we get new Scripture project contents, update react-content-editable + useEffect(() => { + editableScrChapters.current = scrChapters; + forceRefresh(); + }, [scrChapters, forceRefresh]); + + // Keep react-content-editable's ref data up-to-date + const handleChange = ( + evt: ContentEditableEvent, + editedChapter: number, + ) => { + const editedChapterInd = editableScrChapters.current.findIndex( + (scrChapter) => scrChapter.chapter === editedChapter, + ); + editableScrChapters.current[editedChapterInd] = { + ...editableScrChapters.current[editedChapterInd], + contents: evt.target.value, + }; + }; + + /** + * Whether or not the upcoming scrRef update is form this text panel. + * TODO: Not a great way to determine this - should be improved in the future + * */ + const didIUpdateScrRef = useRef(false); + + /** Look for the selection's current BCV and update the ScrRef to it */ + const tryUpdateScrRef = () => { + const windowSel = window.getSelection(); + if (windowSel && windowSel.rangeCount > 0) { + // Set reference to the current verse + // We must be in a chapter + let selectedChapter = -1; + // Intro material should be shown as verse 0, so allow 0 + let selectedVerse = 0; + + // Get the selected node + let node = windowSel.getRangeAt(0).startContainer; + + // Step up the node tree via previous siblings then parents all the way up until we find a chapter and verse + while (node && node !== editorRef.current) { + if (node instanceof Element && node.id) { + const idMatch = node.id.match(regexpChapterVerseId); + if (idMatch && idMatch.length >= 2) { + const verseNum = parseVerse(idMatch[2]); + const chapterNum = parseChapter(idMatch[1]); + if ( + isValidValue(verseNum) && + isValidValue(chapterNum) + ) { + selectedVerse = verseNum; + selectedChapter = chapterNum; + } + } + } + + if (selectedChapter >= 0) { + // We got our results! Done + break; + } else if (node.previousSibling) { + // This node has a previous sibling. Get the lowest node of the previous sibling and try again + node = node.previousSibling; + while (node.hasChildNodes()) { + node = node.lastChild as ChildNode; + } + } else { + // This is the first node of its siblings, so get the parent and try again + node = node.parentNode as ParentNode; + } + } + + // If we found verse info, set the selection + if (selectedChapter > 0) { + updateScrRef({ + book, + chapter: selectedChapter, + verse: selectedVerse, + }); + didIUpdateScrRef.current = true; + } + } + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (!event.altKey) + switch (event.key) { + case 'ArrowDown': + case 'ArrowUp': + case 'ArrowLeft': + case 'ArrowRight': + // TODO: For some reason, the onKeyDown callback doesn't always have the most up-to-date editor.selection. + // As such, I added a setTimeout, which is gross. Please fix this hacky setTimeout + setTimeout(() => { + tryUpdateScrRef(); + }, 1); + break; + default: + break; + } + }; + + // When the scrRef changes, scroll to view + useEffect(() => { + // TODO: Determine if this window should scroll by computing if the verse element is visible instead of using hacky didIUpdateScrRef + if (!didIUpdateScrRef.current && editorRef.current) { + // TODO: Find a better way to wait for the DOM to load before scrolling and hopefully remove scrChapters from dependencies. Ex: Go between Psalm 118:15 and Psalm 119:150 + setTimeout(() => { + // Get the node for the specified chapter editor + let editorElement: Element | undefined; + let chapterElement: Element | undefined; + // Get all the usfm elements (full chapters) + if (editorRef.current) { + const usfmElements = + editorRef.current.getElementsByClassName('usfm'); + for (let i = 0; i < usfmElements.length; i++) { + // If we already found our chapter, we're done + if (editorElement) break; + + // Get the chapter element within the current usfm element + const currChapterElement = + usfmElements[i].querySelector('.usfm_c'); + if (currChapterElement) { + // Check if this usfm element is the right one for this chapter + const idMatch = + currChapterElement.id.match( + regexpChapterVerseId, + ); + if ( + idMatch && + idMatch.length >= 2 && + parseChapter(idMatch[1]) === chapter + ) { + editorElement = usfmElements[i]; + chapterElement = currChapterElement; + break; + } + } + } + + if (editorElement) { + // Get the element for the specified verse + let verseElement: Element | undefined | null; + + // If the verse we're trying to scroll to is 0, scroll to the chapter + if (verse <= 0) { + verseElement = chapterElement; + } else { + verseElement = editorElement.querySelector( + `#cv${chapter}_${verse}`, + ); + } + + if (verseElement) + verseElement.scrollIntoView({ + block: 'center', + }); + } + } + }, 1); + } + didIUpdateScrRef.current = false; + }, [scrChapters, book, chapter, verse]); + + return ( +
+ {editable + ? editableScrChapters.current.map((scrChapter) => ( + + handleChange(e, scrChapter.chapter) + } + // Couldn't use onSelect here because of what is likely a bug in ContentEditable - onSelect fired multiple times, sometimes going to the end of the div before restoring to the correct location + onClick={tryUpdateScrRef} + onKeyDown={onKeyDown} + /> + )) + : scrChapters.map((scrChapter) => ( +
e.preventDefault()} + /> + ))} +
+ ); + }, + getScrChapter, +); diff --git a/react-electron-poc/src/renderer/components/panels/TextPanels/ScriptureTextPanelSlate.tsx b/react-electron-poc/src/renderer/components/panels/TextPanels/ScriptureTextPanelSlate.tsx new file mode 100644 index 0000000..fbfe29d --- /dev/null +++ b/react-electron-poc/src/renderer/components/panels/TextPanels/ScriptureTextPanelSlate.tsx @@ -0,0 +1,536 @@ +import { getScripture } from '@services/ScriptureService'; +import { ScriptureChapterContent } from '@shared/data/ScriptureTypes'; +import { isString, isValidValue } from '@util/Util'; +import { + createElement, + FunctionComponent, + PropsWithChildren, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import './TextPanel.css'; +import { + createEditor, + BaseEditor, + NodeEntry, + Node, + Transforms, + Element, + Text, + Editor, + Point, + Range, + Path, +} from 'slate'; +import { + Slate, + Editable, + withReact, + ReactEditor, + RenderElementProps, +} from 'slate-react'; +import { + ScriptureTextPanelHOC, + ScriptureTextPanelHOCProps, +} from './ScriptureTextPanelHOC'; +import { + getTextFromScrRef, + parseChapter, + parseVerse, +} from '@util/ScriptureUtil'; + +// Slate types +type CustomEditor = BaseEditor & ReactEditor; + +// Types of components: +// Element - contiguous, semantic elements in the document +// - Ex: Marker Element (in-line) that contains a line and is formatted appropriately +// - Note: You cannot have block elements as siblings of inline elements or text. +// - Note: You cannot have children of inline elements other than text, it seems. +// Text - non-contiguous, character-level formatting +// Decoration - computed at render-time based on the content itself +// - helpful for dynamic formatting like syntax highlighting or search keywords, where changes to the content (or some external data) has the potential to change the formatting +// - Ex: The text inside markers is styled literally based on if there's a \ +// The notes are based on certain text in a verse +// Maybe we can make decorations that show the \markers before the lines? Otherwise need to be inline void elements +// Normalizing - enforce rules about your content like structure and such +// - Ex: Enforce that a marker element has the \marker at the beginning + +type MyRenderElementProps = PropsWithChildren< + { + element: T; + } & Omit +>; + +/** Base element props. All elements should have a style */ +type StyleProps = { + style?: string; +}; + +type MarkerProps = { + closingMarker?: boolean; +} & StyleProps; + +type CustomElementProps = { + children: CustomElement[]; +} & StyleProps; + +export type InlineElementProps = { + endSpace?: boolean; + closingMarker?: boolean; +} & MyRenderElementProps; + +export type VerseElementProps = { + type: 'verse'; +} & CustomElementProps; + +export type ParaElementProps = { + type: 'para'; +} & CustomElementProps; + +export type CharElementProps = { + type: 'char'; +} & CustomElementProps; + +export type ChapterElementProps = { + type: 'chapter'; +} & CustomElementProps; + +export type EditorElementProps = { + type: 'editor'; + number: string; + children: CustomElement[]; +}; + +export type CustomElement = + | VerseElementProps + | ParaElementProps + | CharElementProps + | ChapterElementProps + | EditorElementProps; + +type FormattedText = { text: string } & MarkerProps; + +type CustomText = FormattedText; + +declare module 'slate' { + interface CustomTypes { + Editor: CustomEditor; + Element: CustomElement; + Text: CustomText; + } +} + +// Slate components + +/* Renders markers' \marker text with the marker style */ +const Marker = ({ style, closingMarker = false }: MarkerProps) => { + return ( + + {`\\${style}${closingMarker ? '' : ' '}`} + + ); +}; + +/** Prefix added to every marker name for its css class name */ +const MARKER_CLASS_PREFIX = 'usfm_'; + +/** Renders a block-style element. Helper element - not actually rendered on its own */ +const BlockElement = ({ + element: { style }, + attributes, + children, +}: MyRenderElementProps) => ( +
+ + {children} +
+); + +/** Renders an inline-style element. Helper element - not actually rendered on its own */ +const InlineElement = ({ + element: { style }, + attributes, + children, + endSpace = false, + closingMarker = false, +}: InlineElementProps) => ( + + + {children} + {endSpace ?   : undefined} + {closingMarker ? ( + + ) : undefined} + +); + +const VerseElement = (props: MyRenderElementProps) => ( + +); + +/** Renders a complete block of text with an open marker at the start */ +const ParaElement = (props: MyRenderElementProps) => ( + +); + +/** Renders inline text with closed markers around it */ +const CharElement = (props: MyRenderElementProps) => ( + +); + +/** Renders a chapter number */ +const ChapterElement = (props: MyRenderElementProps) => ( + +); + +/** Overall chapter editor element */ +const EditorElement = ({ + element: { number }, + attributes, + children, +}: MyRenderElementProps) => ( +
+ {children} +
+); + +interface ElementInfo { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component: (props: MyRenderElementProps) => JSX.Element; + inline?: boolean; + validStyles?: string[]; +} + +/** All available elements for use in slate editor */ +const EditorElements: { [type: string]: ElementInfo } = { + verse: { component: VerseElement, inline: true, validStyles: ['v'] }, + para: { component: ParaElement, validStyles: ['p', 'q', 'q2'] }, + char: { component: CharElement, inline: true, validStyles: ['nd'] }, + chapter: { component: ChapterElement, validStyles: ['c'] }, + editor: { component: EditorElement }, +}; + +const DefaultElement = ({ attributes, children }: RenderElementProps) => { + return

{children}

; +}; + +const withScrInlines = (editor: CustomEditor): CustomEditor => { + const { isInline } = editor; + + editor.isInline = (element: CustomElement): boolean => + EditorElements[element.type]?.inline || isInline(element); + + return editor; +}; + +const withScrMarkers = (editor: CustomEditor): CustomEditor => { + const { normalizeNode, deleteBackward, deleteForward, onChange } = editor; + + editor.normalizeNode = (entry: NodeEntry): void => { + // const [node, path] = entry; + + // TODO: Figure out how to make sure there is a spot to navigate between markers like \q \v + /* if (Element.isElement(node)) { + const firstChild = Node.child(node, 0); + if (firstChild && Element.isElement(firstChild)) { + Transforms.insertText(editor, '', { at: path.concat(0) }); + } + } */ + + normalizeNode(entry); + }; + + editor.deleteBackward = (...args) => { + const { selection } = editor; + + // Delete in-line markers + if (selection && Range.isCollapsed(selection)) { + // Get the inline element in the path of the selection + const [match] = Editor.nodes(editor, { + match: (n) => + !Editor.isEditor(n) && + Element.isElement(n) && + editor.isInline(n), + }); + + if (match) { + const [, path] = match; + const start = Editor.start(editor, path); + + // If the cursor is at the start of the inline element, remove the element + if (Point.equals(selection.anchor, start)) { + Transforms.unwrapNodes(editor, { at: path }); + return; + } + } + } + + deleteBackward(...args); + }; + + editor.deleteForward = (...args) => { + const { selection } = editor; + + // Delete in-line markers + if (selection && Range.isCollapsed(selection)) { + // Get the inline element in the path of the selection + const [match] = Editor.nodes(editor, { + match: (n) => + !Editor.isEditor(n) && + Element.isElement(n) && + editor.isInline(n), + }); + + if (match) { + const [, path] = match; + const end = Editor.end(editor, path); + + // If the cursor is at the end of the inline element, remove the element + if (Point.equals(selection.anchor, end)) { + Transforms.unwrapNodes(editor, { at: path }); + return; + } + } + } + + deleteForward(...args); + }; + + return editor; +}; + +export interface ScriptureTextPanelProps extends ScriptureTextPanelHOCProps { + scrChapters: ScriptureChapterContent[]; +} + +/** The function to use to get the Scripture chapter content to display */ +const getScrChapter = getScripture; + +export const ScriptureTextPanelSlate = ScriptureTextPanelHOC( + ({ + shortName, + editable, + book, + chapter, + verse, + scrChapters, + updateScrRef, + }: ScriptureTextPanelProps) => { + // Slate editor + // TODO: Put in a useEffect listening for scrChapters and create editors for the number of chapters + const [editor] = useState(() => + withScrMarkers(withScrInlines(withReact(createEditor()))), + ); + + // Render our components for this project + const renderElement = useCallback( + (props: MyRenderElementProps): JSX.Element => { + return createElement( + (EditorElements[props.element.type].component || + DefaultElement) as FunctionComponent, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props as any, + ); + }, + [], + ); + + /** + * Whether or not the upcoming scrRef update is form this text panel. + * TODO: Not a great way to determine this - should be improved in the future + * */ + const didIUpdateScrRef = useRef(false); + + const onSelect = useCallback(() => { + // TODO: For some reason, the onSelect callback doesn't always have the most up-to-date editor.selection. + // As such, I added a setTimeout, which is gross. Please fix this hacky setTimeout + // One possible solution would be to listen for mouse clicks and arrow key events and see if editor.selection is updated by then. + // Or use onChange and keep track of selection vs previous selection if selection is updated. + // Or try useSlateSelection hook again + setTimeout(() => { + if (editor.selection) { + // Set reference to the current verse + // We must be in a chapter + let selectedChapter = -1; + // Intro material should show as verse 0, so allow 0 + let selectedVerse = 0; + + // Get the selected node + let nodeEntry: NodeEntry | undefined = Editor.node( + editor, + editor.selection.anchor, + ); + + // Step up the node tree via previous siblings then parents all the way up until we find a chapter and verse + while (nodeEntry && !Editor.isEditor(nodeEntry[0])) { + const [node, path] = nodeEntry as NodeEntry; + if (Element.isElement(node)) { + if (selectedVerse <= 0 && node.type === 'verse') { + // It's a verse, so try to parse its text and use that as the verse + const verseText = Node.string(node); + const verseNum = parseVerse(verseText); + if (isValidValue(verseNum)) { + selectedVerse = verseNum; + } + } else if ( + selectedChapter < 0 && + node.type === 'chapter' + ) { + // It's a chapter, so try to parse its text and use that as the chapter + const chapterText = Node.string(node); + const chapterNum = parseChapter(chapterText); + if (isValidValue(chapterNum)) { + selectedChapter = chapterNum; + } + } + } + + if (selectedChapter >= 0) { + // We got our results! Done + break; + } else if (Path.hasPrevious(path)) { + // This node has a previous sibling. Get the lowest node of the previous sibling and try again + nodeEntry = Editor.last( + editor, + Path.previous(path), + ); + } else { + // This is the first node of its siblings, so get the parent and try again + nodeEntry = Editor.parent(editor, path); + } + } + + // If we found verse info, set the selection + if (selectedChapter > 0) { + updateScrRef({ + book, + chapter: selectedChapter, + verse: selectedVerse, + }); + didIUpdateScrRef.current = true; + } + } + }, 1); + }, [editor, updateScrRef, book]); + + // When we get new Scripture project contents, update slate + useEffect(() => { + if (scrChapters && scrChapters.length > 0) { + // TODO: Save the selection + + // Unselect + Transforms.deselect(editor); + + // Replace the editor's contents + editor.children = scrChapters.map( + (scrChapter) => + ({ + type: 'editor', + number: scrChapter.chapter.toString(), + children: isString(scrChapter.contents) + ? [ + { + // TODO: When loading, the contents come as a string. Consider how to improve the loading value in ScriptureTextPanelHOC + text: scrChapter.contents as unknown as string, + } as CustomText, + ] + : (scrChapter.contents as CustomElement[]), + } as EditorElementProps), + ); + + // TODO: May need to call Editor.normalize, potentially with option { force: true } + // Editor.normalize(editor); + + // TODO: Restore cursor to new ScrRef + + editor.onChange(); + } + }, [editor, scrChapters]); + + // When the scrRef changes, scroll to view + useEffect(() => { + // TODO: Determine if this window should scroll by computing if the verse element is visible instead of using hacky didIUpdateScrRef + if (!didIUpdateScrRef.current) { + // TODO: Find a better way to wait for the DOM to load before scrolling and hopefully remove scrChapters from dependencies. Ex: Go between Psalm 118:15 and Psalm 119:150 + setTimeout(() => { + // Get the node for the specified chapter editor + const [editorNodeEntry] = Editor.nodes(editor, { + at: [], + match: (n) => + !Editor.isEditor(n) && + Element.isElement(n) && + n.type === 'editor' && + parseChapter(n.number) === chapter, + /* n.type === 'chapter' && + parseChapter(Node.string(n)) === chapter, */ + }); + if (editorNodeEntry) { + const [, editorNodePath] = editorNodeEntry; + + // Make a match function that matches on the chapter node if verse 0 or the verse node otherwise + const matchVerseNode = + verse > 0 + ? (n: Node) => + Element.isElement(n) && + n.type === 'verse' && + parseVerse(Node.string(n)) === verse + : (n: Node) => + Element.isElement(n) && + n.type === 'chapter' && + parseChapter(Node.string(n)) === chapter; + + // Get the node for the specified verse + const [verseNodeEntry] = Editor.nodes(editor, { + at: [ + editorNodePath, + Editor.last(editor, editorNodePath)[1], + ], + match: matchVerseNode, + }); + if (verseNodeEntry) { + const [verseNode] = verseNodeEntry; + + try { + // Get the dom element for this verse marker and scroll to it + const verseDomElement = ReactEditor.toDOMNode( + editor, + verseNode, + ); + + verseDomElement.scrollIntoView({ + block: 'center', + }); + } catch (e) { + console.warn( + `Not able to scroll to ${getTextFromScrRef({ + book, + chapter, + verse, + })}`, + ); + console.warn(e); + } + } + } + }, 1); + } + didIUpdateScrRef.current = false; + }, [editor, scrChapters, book, chapter, verse]); + + return ( +
+ + + +
+ ); + }, + getScrChapter, +); diff --git a/react-electron-poc/src/renderer/services/ScriptureService.ts b/react-electron-poc/src/renderer/services/ScriptureService.ts index 95e0a4c..529cf23 100644 --- a/react-electron-poc/src/renderer/services/ScriptureService.ts +++ b/react-electron-poc/src/renderer/services/ScriptureService.ts @@ -18,24 +18,33 @@ export const getScripture = async ( chapter = -1, ): Promise => { try { - return chapter >= 0 - ? await window.electronAPI.scripture - .getScriptureChapter(shortName, bookNum, chapter) - .then((result) => [result]) - : await window.electronAPI.scripture.getScriptureBook( - shortName, - bookNum, - ); + const scrChapterContents = + chapter >= 0 + ? await window.electronAPI.scripture + .getScriptureChapter(shortName, bookNum, chapter) + .then((result) => [result]) + : await window.electronAPI.scripture.getScriptureBook( + shortName, + bookNum, + ); + return scrChapterContents.map((scrChapterContent) => ({ + ...scrChapterContent, + contents: JSON.parse( + scrChapterContent.contents as unknown as string, // Parsing from string, but it's nice to know getScripture intends to send json of known type + ), + })); } catch (e) { console.log(e); return [ { chapter, - contents: { - text: `Could not get contents of ${shortName} ${getTextFromScrRef( - { book: bookNum, chapter, verse: -1 }, - )}.`, - }, + contents: [ + { + text: `Could not get contents of ${shortName} ${getTextFromScrRef( + { book: bookNum, chapter, verse: -1 }, + )}.`, + }, + ], }, ]; } diff --git a/react-electron-poc/src/renderer/services/SettingsService.ts b/react-electron-poc/src/renderer/services/SettingsService.ts new file mode 100644 index 0000000..63f7ef9 --- /dev/null +++ b/react-electron-poc/src/renderer/services/SettingsService.ts @@ -0,0 +1,9 @@ +export const getSetting = (key: string): T | undefined => { + const setting = localStorage.getItem(key); + return setting ? JSON.parse(setting) : setting; +}; + +export const setSetting = (key: string, value: T | undefined) => { + if (value) localStorage.setItem(key, JSON.stringify(value)); + else localStorage.removeItem(key); +}; diff --git a/react-electron-poc/src/renderer/util/ScriptureUtil.ts b/react-electron-poc/src/renderer/util/ScriptureUtil.ts index bed7904..6251121 100644 --- a/react-electron-poc/src/renderer/util/ScriptureUtil.ts +++ b/react-electron-poc/src/renderer/util/ScriptureUtil.ts @@ -1,5 +1,5 @@ import { ScriptureReference } from '@shared/data/ScriptureTypes'; -import { isString } from './Util'; +import { isString, isValidValue } from './Util'; const scrBookNames: string[][] = [ ['ERR', 'ERROR'], @@ -123,6 +123,18 @@ export const offsetVerse = ( offset: number, ): ScriptureReference => ({ ...scrRef, verse: scrRef.verse + offset }); +/** Parse a verse number from a string */ +export const parseVerse = (verseText: string): number | undefined => { + const verseNum = parseInt(verseText, 10); + return isValidValue(verseNum) ? verseNum : undefined; +}; + +/** Parse a chapter number from a string */ +export const parseChapter = (chapterText: string): number | undefined => { + // For now, this is the same as parseVerse. Maybe there could be other constraints in the future + return parseVerse(chapterText); +}; + const regexpScrRef = /([^ ]+) ([^:]+):(.+)/; export const getScrRefFromText = (refText: string): ScriptureReference => { if (!refText) return { book: -1, chapter: -1, verse: -1 }; diff --git a/react-electron-poc/src/renderer/util/Util.ts b/react-electron-poc/src/renderer/util/Util.ts index 04e06d7..857d4d4 100644 --- a/react-electron-poc/src/renderer/util/Util.ts +++ b/react-electron-poc/src/renderer/util/Util.ts @@ -21,7 +21,7 @@ export function isString(o: unknown) { * @param val value to evaluate * @returns whether the value is truthy, false, or 0 */ -export function isValidValue(val: unknown): boolean { +export function isValidValue(val: unknown): val is NonNullable { return !!val || val === false || val === 0; } diff --git a/react-electron-poc/src/shared/data/ScriptureTypes.ts b/react-electron-poc/src/shared/data/ScriptureTypes.ts index 8b49e6b..2491f16 100644 --- a/react-electron-poc/src/shared/data/ScriptureTypes.ts +++ b/react-electron-poc/src/shared/data/ScriptureTypes.ts @@ -1,3 +1,4 @@ + export interface ScriptureReference { book: number; chapter: number; @@ -10,8 +11,8 @@ export interface ResourceInfo { } /** Slate object for Scripture editing */ -export interface ScriptureContent { - text: string; +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ScriptureContent extends CustomDescendant { } /** Scipture chapter contents along with which chapter it is */ @@ -22,7 +23,7 @@ export interface ScriptureChapter { /** Scipture chapter contents Slate object along with which chapter it is */ export interface ScriptureChapterContent extends ScriptureChapter { - contents: ScriptureContent; + contents: ScriptureContent[]; } /** Scripture chapter string (usx, usfm, html, etc) along with which chapter it is */