Merge branch 'master' of https://github.com/naturalcrit/homebrewery into delete-route-for-account-deletion

This commit is contained in:
Víctor Losada Hernández
2026-09-12 14:58:31 +02:00
224 changed files with 21859 additions and 15848 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ orbs:
jobs:
build:
docker:
- image: cimg/node:20.18.0
- image: cimg/node:26.4
- image: mongo:4.4
working_directory: ~/homebrewery
@@ -27,7 +27,7 @@ jobs:
# fallback to using the latest cache if no exact match is found
- v1-dependencies-
- run: sudo npm install -g npm@10.8.2
- run: sudo npm install -g npm@11.17.0
- node/install-packages:
app-dir: ~/homebrewery
cache-path: node_modules
@@ -45,7 +45,7 @@ jobs:
test:
docker:
- image: cimg/node:20.17.0
- image: cimg/node:26.4
working_directory: ~/homebrewery
parallelism: 1
-4
View File
@@ -66,10 +66,6 @@ updates:
- dependency-name: "@babel/preset-react"
versions:
- 7.13.13
- dependency-name: codemirror
versions:
- 5.59.3
- 5.60.0
- dependency-name: classnames
versions:
- 2.3.0
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine
FROM node:26.4.0-alpine
RUN apk --no-cache add git
ENV NODE_ENV=docker
+13 -2
View File
@@ -47,9 +47,7 @@ Make an changes you need to `config/docker.json` then build the image. If it doe
"naturalcrit_url" : "local.naturalcrit.com:8010",
"secret" : "secret",
"web_port" : 8000,
"enable_v3" : true,
"mongodb_uri": "mongodb://172.17.0.2/homebrewery",
"enable_themes" : true,
}
```
@@ -90,6 +88,13 @@ docker run --name homebrewery-mongodb -d --restart unless-stopped -v mongodata:/
docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v $(pwd)/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest
```
**NOTE:** If you are running from the Windows command line, this will not work as `$(pwd)` is not valid syntax. Use this command instead:
```shell
# Make sure you run this in the homebrewery directory
docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v %cd%/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest
```
## Updating the Image
When Homebrewery code updates, your docker container will not automatically follow the changes. To do so you will need to rebuild your homebrewery image.
@@ -117,3 +122,9 @@ docker-compose build homebrewery
docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v $(pwd)/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest
```
**NOTE:** If you are running from the Windows command line, this will not work as `$(pwd)` is not valid syntax. Use this command instead:
```shell
# Make sure you run this in the homebrewery directory
docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v %cd%/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest
```
+4 -2
View File
@@ -75,8 +75,9 @@ it using the two commands:
1. `npm install`
1. `npm start`
You should now be able to go to [http://localhost:8000](http://localhost:8000)
in your browser and use The Homebrewery offline.
When the Homebrewery server is started for the first time, it will modify the database to create the indexes required for better Homebrewery performance. This may take a few moments to complete for each index, dependent on how much content is in your local database - a brand new, empty database should be done in seconds.
On completion, you should be able to go to [http://localhost:8000](http://localhost:8000) in your browser and use The Homebrewery offline.
If you had any issue at all, here are some links that may be useful:
- [Course](https://learn.mongodb.com/courses/m103-basic-cluster-administration) on cluster administration, useful for beginners
@@ -145,3 +146,4 @@ your contribution to the project, please join our [gitter chat][gitter-url].
[github-pr-docs-url]: https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request
[gitter-url]: https://gitter.im/naturalcrit/Lobby
+179 -1
View File
@@ -83,11 +83,189 @@ pre {
.page .exampleTable td,th {
border:1px dashed #00000030;
}
.page .df {
font-size: 2em;
vertical-align: middle;
}
```
## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### Monday 9/07/2026 - v3.23.0
{{taskList
##### firstmatekidd (new contributor!)
* [x] Add nav buttons to tab order for accesibility
* [x] Add title to brewRenderer iframe for accessibility
* [x] Add aria labels to toolbars
Partially fixes issue [#3032](https://github.com/naturalcrit/homebrewery/issues/3032)
##### ReinaSweet (new contributor!)
* [x] Added security to prevent scripts running in brews
##### Gazook89
* [x] Lazy load brew images for faster first load
* [x] Fix snippet menus getting cut off behind divider bar
Fixes issue [#3840](https://github.com/naturalcrit/homebrewery/issues/3840)
##### Abquintic
* [x] Fix variables not hoisting in later pages
##### 5e-Cleric
* [x] Add image preview when hovering over image syntax
Fixes issue [#2037](https://github.com/naturalcrit/homebrewery/issues/2037)
* [x] Remember editor theme across tabs
Fixes issue [#4858](https://github.com/naturalcrit/homebrewery/issues/4858)
* [x] Add ability to remove co-authors if you are the owner
Fixes part of issue [#4101](https://github.com/naturalcrit/homebrewery/issues/4101)
##### G-Ambatte
* [x] Fix editor panel shrinking when openingdev tools
Fixes issue [#4866](https://github.com/naturalcrit/homebrewery/issues/4866)
* [x] Added security to prevent scripts running in brews
Fixes issue [#4904](https://github.com/naturalcrit/homebrewery/issues/4904)
##### Abquintic, G-Ambatte, Gazook89, 5e-Cleric
* [x] Multiple background fixes and code cleanups
}}
### Wednesday 5/13/2026 - v3.22.1
{{taskList
##### Frederlk (new Contributor!)
* [x] Add "share current page" to {{openSans **SHARE :fas_share_nodes:**}} menu
##### Calculuschild, 5e-Cleric
* [x] Fix brews triggering unneeded save when loaded
##### G-Ambatte
* [x] Fix brews with `$` in text getting out of sync on save
##### 5e-Cleric, Gazook89
* [x] Fix various issues with Codemirror 6
Fixes issues [#4771](https://github.com/naturalcrit/homebrewery/issues/4771), [#4583](https://github.com/naturalcrit/homebrewery/issues/4783)
}}
\page
### Saturday 4/04/2026 - v3.21.0
{{taskList
##### Gazook89
* [x] Allow custom {{openSans **:fas_table_list: SNIPPETS**}} dropdown to be inserted mid-line
##### abquintic
* [x] Move example snippet images out of imgur (for folks without imgur access)
##### 5e-Cleric
* [x] Add auto-suggest to tag entry input box
* [x] Replace all example artwork with
* [x] Added tooltips to the {{openSans :fas_circle_info: **Properties**}} menu
* [x] Removed {{openSans **SYSTEMS**}} checkboxes from {{openSans :fas_circle_info: **Properties**}} menu; instead {{openSans **TAGS**}} should be used for this purpose
* [x] Replace all AI-generated art with public domain art
* [x] Major backend refactor to use Vite
##### A1Asriel (new contributor!)
* [x] Add fix for column breaks on Firefox
Fixes issues [#543](https://github.com/naturalcrit/homebrewery/issues/543), [#2473](https://github.com/naturalcrit/homebrewery/issues/2473), [#3712](https://github.com/naturalcrit/homebrewery/issues/3712)
##### G-Ambatte, abquintic, 5e-Cleric
* [x] Multiple other backend fixes and refactors
}}
### Friday 1/11/2026 - v3.20.1
{{taskList
##### calculuschild
* [x] Add D100 "ball" dice icons `:d100:` :df_d100_05:
##### G-Ambatte
* [x] Fix transparent edge on back cover image
Fixes issue [#4551](https://github.com/naturalcrit/homebrewery/issues/4551)
* [x] Fix "Out of sync" error when document contains extended unicode characters
Fixes issue [#4583](https://github.com/naturalcrit/homebrewery/issues/4583)
##### 5e-Cleric
* [x] Fix page count error on Vault
* [x] Fix cover page footnote set to all-caps
Fixes issue [#4559](https://github.com/naturalcrit/homebrewery/issues/4559)
}}
### Friday 11/14/2025 - v3.20.0
{{taskList
##### calculuschild
* [x] Cleanup and removal of redundant code (home/new/edit pages)
* [x] Compress brew payloads and when saving to speed up saving and reduce bandwidth usage
##### Gazook89
* [x] Cleanup (reorganizing files and folders)
##### G-Ambatte
* [x] Fix default save location failing on new documents
Fixes issue [#4437](https://github.com/naturalcrit/homebrewery/issues/3175)
* [x] Fix usernames with special symbols unable to open userpage
Fixes issue [#807](https://github.com/naturalcrit/homebrewery/issues/807)
* [x] Cleanup (tests, documentation, localstorage key names)
Fixes issues [#4119](https://github.com/naturalcrit/homebrewery/issues/4119), [#4443](https://github.com/naturalcrit/homebrewery/issues/4443), [#4454](https://github.com/naturalcrit/homebrewery/issues/4454)
* [x] Added indexes to Mongo schema for speed improvements on local installs
##### 5e-Cleric
* [x] Better handling for errors during brew deletion
* [x] Improve file import error messaging
* [x] Tweaked editor visuals (scrollbar, dev environment, cursor type)
Fixes issue [#2689](https://github.com/naturalcrit/homebrewery/issues/2689)
* [x] Add support for math symbols in curly blocks for CSS `calc()`
Fixes issue [#3175](https://github.com/naturalcrit/homebrewery/issues/3175)
##### abquintic
* [x] Added new {{openSans **:fas_copyright: LICENSE**}} snippets (DriveThruRPG, AELF, GNU, WotC, Mongoose, etc.) with logos
* [x] Added d100 dice icons via `:icon_name:` syntax
* [x] Fix Firefox crash from very long blockquotes
Fixes issue [#3426](https://github.com/naturalcrit/homebrewery/issues/3426)
* [x] Allow more symbol characters in CSS vars inside curly blocks
Fixes issue [#4201](https://github.com/naturalcrit/homebrewery/issues/4201)
* [x] Remove duplicate columnbreak at end of page
Fixes issue [#4401](https://github.com/naturalcrit/homebrewery/issues/4401)
* [x] Fix editor losing scroll position after page resize
Fixes issue [#2963](https://github.com/naturalcrit/homebrewery/issues/2963)
##### Emmanuel Ferdman (first contribution!)
* [x] Fixed edge case crash on admin page
}}
\page
### Wednesday 7/09/2025 - v3.19.3
{{taskList
@@ -203,7 +381,7 @@ Fixes issue [#1729](https://github.com/naturalcrit/homebrewery/issues/1729)
Fixes issue [#4079](https://github.com/naturalcrit/homebrewery/issues/4079)
##### Calculuschild
* [x] `꞉꞉꞉꞉` now produces `<br>` instead of a `<div>`
* [x] `꞉꞉꞉꞉` now produces `<br>` instead of a `&lt;div>`
* [x] Fix typos in tables freezing the editor
Fixes issue [#4059](https://github.com/naturalcrit/homebrewery/issues/4059)
+7 -5
View File
@@ -1,21 +1,23 @@
import './admin.less';
import React, { useEffect, useState } from 'react';
const BrewUtils = require('./brewUtils/brewUtils.jsx');
const NotificationUtils = require('./notificationUtils/notificationUtils.jsx');
import BrewUtils from './brewUtils/brewUtils.jsx';
import NotificationUtils from './notificationUtils/notificationUtils.jsx';
import AuthorUtils from './authorUtils/authorUtils.jsx';
import LockTools from './lockTools/lockTools.jsx';
const tabGroups = ['brew', 'notifications', 'authors', 'locks'];
const ADMIN_TAB = 'HB_adminPage_currentTab';
const Admin = ()=>{
const [currentTab, setCurrentTab] = useState('');
useEffect(()=>{
setCurrentTab(localStorage.getItem('hbAdminTab') || 'brew');
setCurrentTab(localStorage.getItem(ADMIN_TAB) || 'brew');
}, []);
useEffect(()=>{
localStorage.setItem('hbAdminTab', currentTab);
localStorage.setItem(ADMIN_TAB, currentTab);
}, [currentTab]);
return (
@@ -47,4 +49,4 @@ const Admin = ()=>{
);
};
module.exports = Admin;
export default Admin;
+10 -8
View File
@@ -1,11 +1,9 @@
@import 'naturalcrit/styles/reset.less';
@import 'naturalcrit/styles/elements.less';
@import 'naturalcrit/styles/animations.less';
@import 'naturalcrit/styles/colors.less';
@import 'naturalcrit/styles/tooltip.less';
@import './themes/fonts/iconFonts/fontAwesome.less';
@import 'font-awesome/css/font-awesome.css';
@import '@sharedStyles/reset.less';
@import '@sharedStyles/elements.less';
@import '@sharedStyles/animations.less';
@import '@sharedStyles/colors.less';
@import '@sharedStyles/tooltip.less';
@import '@themes/fonts/iconFonts/fontAwesome.less';
html,body, #reactContainer, .naturalCrit { min-height : 100%; }
@@ -113,6 +111,10 @@ body {
vertical-align : middle;
text-align : center;
border-right : 1px solid;
max-width:50ch;
overflow:hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:last-child { border-right : none; }
}
@@ -84,4 +84,4 @@ const authorLookup = ()=>{
);
};
module.exports = authorLookup;
export default authorLookup;
+1 -1
View File
@@ -10,4 +10,4 @@ const authorUtils = ()=>{
);
};
module.exports = authorUtils;
export default authorUtils;
@@ -1,72 +1,176 @@
const React = require('react');
const createClass = require('create-react-class');
import React, { useState } from 'react';
import request from 'superagent';
import Moment from 'moment';
const request = require('superagent');
const BrewCleanup = ({})=>{
const [junkBrewCollection, setJunkBrewCollection] = useState([]);
const [lostBrewCollection, setLostBrewCollection] = useState([]);
const [pendingJunk, setPendingJunk] = useState(false);
const [pendingLost, setPendingLost] = useState(false);
const [error, setError] = useState(null);
const BrewCleanup = createClass({
displayName : 'BrewCleanup',
getDefaultProps(){
return {};
},
getInitialState() {
return {
count : 0,
const find = async (type)=>{
pending : false,
primed : false,
err : null
};
},
prime(){
this.setState({ pending: true });
request.get('/admin/cleanup')
.then((res)=>this.setState({ count: res.body.count, primed: true }))
.catch((err)=>this.setState({ error: err }))
.finally(()=>this.setState({ pending: false }));
},
cleanup(){
this.setState({ pending: true });
if(type === 'junk') try {
setPendingJunk(true);
const res = await request.get('/admin/cleanupJunk');
request.post('/admin/cleanup')
.then((res)=>this.setState({ count: res.body.count }))
.catch((err)=>this.setState({ error: err }))
.finally(()=>this.setState({ pending: false, primed: false }));
},
renderPrimed(){
if(!this.state.primed) return;
if(!this.state.count){
return <div className='result noBrews'>No Matching Brews found.</div>;
setJunkBrewCollection(res.body.brewCollection);
} catch (err) {
setError(err);
} finally {
setPendingJunk(false);
}
if(type === 'lost') try {
setPendingLost(true);
const res = await request.get('/admin/cleanupLost');
setLostBrewCollection(res.body.brewCollection);
} catch (err) {
setError(err);
} finally {
setPendingLost(false);
}
};
const cleanup = async (type)=>{
if(type === 'junk') try {
setPendingJunk(true);
console.log('deleting junk');
const res = await request.post('/admin/cleanupJunk');
} catch (err) {
setError(err);
} finally {
setPendingJunk(false);
setJunkBrewCollection([]);
}
if(type === 'lost') try {
setPendingLost(true);
const res = await request.post('/admin/cleanupLost');
} catch (err) {
setError(err);
} finally {
setPendingLost(false);
setLostBrewCollection([]);
}
};
const renderBrewList = (type)=>{
const brewList = type === 'lost' ? lostBrewCollection : junkBrewCollection;
if(!brewList || brewList.length === 0) {
return <>
<h3>{`Results - No brews found` }</h3>
<table className='resultsTable'>
<thead>
<tr>
<th>Title</th>
<th>Last Update</th>
<th>last viewed</th>
<th>Storage</th>
</tr>
</thead>
<tbody>
<tr>
<td colSpan={4}><strong>"No brews found"</strong></td>
</tr>
</tbody>
</table>
</>;
}
console.log(type);
console.log(brewList);
return <>
<h3>{`Results - ${brewList.length} brews` }</h3>
<table className='resultsTable'>
<thead>
<tr>
<th>Title</th>
<th>Last Update</th>
<th>last viewed</th>
<th>Storage</th>
</tr>
</thead>
<tbody>
{brewList
.sort((a, b)=>{ // Sort brews from most recently updated
if(a.lastViewed > b.lastViewed) return -1;
return 1;
})
.map((brew, idx)=>{
return <tr key={idx}>
<td><strong>{brew.title || 'No Title'}</strong></td>
<td style={{ width: '200px' }}>{Moment(brew.updatedAt).fromNow()}</td>
<td>{brew.lastViewed ? Moment(brew.lastViewed).fromNow() : 'No last viewed date'}</td>
<td>{brew.googleId ? 'Google' : 'Homebrewery'}</td>
</tr>
})}
</tbody>
</table>
</>;
};
const renderFound = (type)=>{
const deleteButton = !(type === 'junk' && junkBrewCollection.length === 0 || type === 'lost' && lostBrewCollection.length === 0);
return <div className='result'>
<button onClick={this.cleanup} className='remove'>
{this.state.pending
{deleteButton && <button onClick={()=>cleanup(type)} className='remove'>
{pendingLost && type === "lost" || pendingJunk && type === "junk"
? <i className='fas fa-spin fa-spinner' />
: <span><i className='fas fa-times' /> Remove</span>
}
</button>
<span>Found {this.state.count} Brews that could be removed. </span>
}
{renderBrewList(type)}
</div>;
},
render(){
return <div className='brewUtil brewCleanup'>
<h2> Brew Cleanup </h2>
<p>Removes very short brews to tidy up the database</p>
};
const renderJunkBrewCleanup = ()=>{
return <div className='junk'>
<h3> Junk brews</h3>
<p>Queries unauthored brews that have not been viewed or <br/>updated in 30 days and are shorter than 140 bytes (up to 300)</p>
<button onClick={this.prime} className='query'>
{this.state.pending
<button onClick={()=>find('junk')} className='query'>
{pendingJunk
? <i className='fas fa-spin fa-spinner' />
: 'Query Brews'
}
</button>
{this.renderPrimed()}
{renderFound('junk')}
{this.state.error
&& <div className='error noBrews'>{this.state.error.toString()}</div>
}
{error && <div className='error noBrews'>{error.toString()}</div>}
</div>;
}
});
};
const renderLostBrewCleanup = ()=>{
return <div className='lost'>
<h3> Lost brews</h3>
<p>Queries unauthored brews that have not been <br/>updated or viewed for 2 years (up to 500)</p>
module.exports = BrewCleanup;
<button onClick={()=>find('lost')} className='query'>
{pendingLost
? <i className='fas fa-spin fa-spinner' />
: 'Query Brews'
}
</button>
{renderFound('lost')}
{error && <div className='error noBrews'>{error.toString()}</div>}
</div>;
};
return <div className='brewUtil brewCleanup'>
<h2> Brew Cleanup </h2>
{renderJunkBrewCleanup()}
<br/>
<br/>
{renderLostBrewCleanup()}
</div>;
};
export default BrewCleanup;
@@ -1,8 +1,8 @@
const React = require('react');
const createClass = require('create-react-class');
const request = require('superagent');
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
const BrewCompress = createClass({
const BrewCompress = createReactClass({
displayName : 'BrewCompress',
getDefaultProps(){
return {};
@@ -85,4 +85,4 @@ const BrewCompress = createClass({
}
});
module.exports = BrewCompress;
export default BrewCompress;
@@ -1,12 +1,11 @@
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
import cx from 'classnames';
const request = require('superagent');
const Moment = require('moment');
import Moment from 'moment';
const BrewLookup = createClass({
const BrewLookup = createReactClass({
getDefaultProps() {
return {};
},
@@ -110,4 +109,4 @@ const BrewLookup = createClass({
}
});
module.exports = BrewLookup;
export default BrewLookup;
+13 -15
View File
@@ -1,15 +1,14 @@
const React = require('react');
const createClass = require('create-react-class');
require('./brewUtils.less');
import React from 'react';
import './brewUtils.less';
const BrewCleanup = require('./brewCleanup/brewCleanup.jsx');
const BrewLookup = require('./brewLookup/brewLookup.jsx');
const BrewCompress = require ('./brewCompress/brewCompress.jsx');
const Stats = require('./stats/stats.jsx');
import BrewCleanup from './brewCleanup/brewCleanup.jsx';
import BrewLookup from './brewLookup/brewLookup.jsx';
import BrewCompress from './brewCompress/brewCompress.jsx';
import Stats from './stats/stats.jsx';
const BrewUtils = createClass({
render : function(){
return <>
const BrewUtils = ()=>{
return (
<>
<Stats />
<hr />
<BrewLookup />
@@ -17,8 +16,7 @@ const BrewUtils = createClass({
<BrewCleanup />
<hr />
<BrewCompress />
</>;
}
});
module.exports = BrewUtils;
</>
);
};
export default BrewUtils;
+2
View File
@@ -1,3 +1,5 @@
@import '@sharedStyles/colors.less';
.brewUtil {
.result {
margin-top : 20px;
+5 -6
View File
@@ -1,9 +1,8 @@
const React = require('react');
const createClass = require('create-react-class');
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
const request = require('superagent');
const Stats = createClass({
const Stats = createReactClass({
displayName : 'Stats',
getDefaultProps(){
return {};
@@ -43,4 +42,4 @@ const Stats = createClass({
}
});
module.exports = Stats;
export default Stats;
+8 -8
View File
@@ -1,11 +1,11 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
require('./lockTools.less');
const React = require('react');
const createClass = require('create-react-class');
import './lockTools.less';
import React from 'react';
import createReactClass from 'create-react-class';
import request from '../../homebrew/utils/request-middleware.js';
const LockTools = createClass({
const LockTools = createReactClass({
displayName : 'LockTools',
getInitialState : function() {
return {
@@ -55,7 +55,7 @@ const LockTools = createClass({
}
});
const LockBrew = createClass({
const LockBrew = createReactClass({
displayName : 'LockBrew',
getInitialState : function() {
// Default values
@@ -183,7 +183,7 @@ const LockBrew = createClass({
}
});
const LockTable = createClass({
const LockTable = createReactClass({
displayName : 'LockTable',
getDefaultProps : function() {
return {
@@ -273,7 +273,7 @@ const LockTable = createClass({
}
});
const LockLookup = createClass({
const LockLookup = createReactClass({
displayName : 'LockLookup',
getDefaultProps : function() {
return {
@@ -339,4 +339,4 @@ const LockLookup = createClass({
}
});
module.exports = LockTools;
export default LockTools;
+8
View File
@@ -0,0 +1,8 @@
import { createRoot } from 'react-dom/client';
import Admin from './admin.jsx';
import { bootstrapAnchorPositioningPolyfill } from '@components/anchorPositioningPolyfill.js';
const props = window.__INITIAL_PROPS__ || {};
createRoot(document.getElementById('reactRoot')).render(<Admin {...props} />);
bootstrapAnchorPositioningPolyfill();
@@ -1,7 +1,6 @@
require('./notificationAdd.less');
const React = require('react');
const { useState, useRef } = require('react');
const request = require('superagent');
import './notificationAdd.less';
import React, { useState, useRef } from 'react';
import request from 'superagent';
const NotificationAdd = ()=>{
const [notificationResult, setNotificationResult] = useState(null);
@@ -106,4 +105,4 @@ const NotificationAdd = ()=>{
);
};
module.exports = NotificationAdd;
export default NotificationAdd;
@@ -1,9 +1,7 @@
require('./notificationLookup.less');
const React = require('react');
const { useState } = require('react');
const request = require('superagent');
const Moment = require('moment');
import './notificationLookup.less';
import React, { useState } from 'react';
import request from 'superagent';
import Moment from 'moment';
const NotificationDetail = ({ notification, onDelete })=>(
<>
@@ -102,4 +100,4 @@ const NotificationLookup = ()=>{
);
};
module.exports = NotificationLookup;
export default NotificationLookup;
@@ -1,7 +1,6 @@
const React = require('react');
const NotificationLookup = require('./notificationLookup/notificationLookup.jsx');
const NotificationAdd = require('./notificationAdd/notificationAdd.jsx');
import React from 'react';
import NotificationLookup from './notificationLookup/notificationLookup.jsx';
import NotificationAdd from './notificationAdd/notificationAdd.jsx';
const NotificationUtils = ()=>{
return (
@@ -12,4 +11,4 @@ const NotificationUtils = ()=>{
);
};
module.exports = NotificationUtils;
export default NotificationUtils;
+11 -4
View File
@@ -71,10 +71,14 @@ const Anchored = ({ children })=>{
// forward ref for AnchoredTrigger
const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>(
<button
ref={ref}
ref={(el)=>{
// setAttribute bypasses React's style sanitization so the anchor polyfill can read it
el?.setAttribute('style', `anchor-name: --${props.id}`);
if(typeof ref === 'function') ref(el);
else if(ref) ref.current = el;
}}
className={`anchored-trigger${visible ? ' active' : ''} ${className}`}
onClick={toggleVisibility}
style={{ anchorName: `--${props.id}` }} // setting anchor properties here allows greater recyclability.
{...props}
>
{children}
@@ -84,9 +88,12 @@ const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, class
// forward ref for AnchoredBox
const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>(
<div
ref={ref}
ref={(el)=>{
el?.setAttribute('style', `position-anchor: --${anchorId}`);
if(typeof ref === 'function') ref(el);
else if(ref) ref.current = el;
}}
className={`anchored-box${visible ? ' active' : ''} ${className}`}
style={{ positionAnchor: `--${anchorId}` }} // setting anchor properties here allows greater recyclability.
{...props}
>
{children}
-2
View File
@@ -4,8 +4,6 @@
position : absolute;
visibility : hidden;
justify-self : anchor-center;
@supports (inset-block-start: anchor(bottom)) {
inset-block-start : anchor(bottom);
}
&.active { visibility : visible; }
}
@@ -0,0 +1,27 @@
/*
This file basically checks support for Anchor Positioning API in the browser,
and then loads the Oddbird polyfill if support is lacking.
*/
let polyfillPromise;
// look for `anchorName` in the computed styles
const supportsAnchorPositioning = ()=>'anchorName' in document.documentElement.style;
export const bootstrapAnchorPositioningPolyfill = ()=>{
if(supportsAnchorPositioning()) return Promise.resolve(false);
if(polyfillPromise) return polyfillPromise;
polyfillPromise = (async ()=>{
try {
const { default: polyfill } = await import('@oddbird/css-anchor-positioning/fn');
await polyfill();
return true;
} catch (error){
polyfillPromise = undefined;
throw error;
}
})();
return polyfillPromise;
};
+404
View File
@@ -0,0 +1,404 @@
/* eslint max-lines: ["error", { "max": 405 }] */
import './codeEditor.less';
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import {
EditorView,
keymap,
lineNumbers,
highlightActiveLineGutter,
highlightActiveLine,
scrollPastEnd,
Decoration,
drawSelection,
dropCursor,
rectangularSelection,
crosshairCursor,
} from '@codemirror/view';
import { EditorState, Compartment, StateEffect, StateField } from '@codemirror/state';
import {
unfoldAll as unfoldAllCmd,
foldGutter,
foldKeymap,
foldEffect,
foldState,
syntaxHighlighting,
} from '@codemirror/language';
import { defaultKeymap, history, undo, redo, undoDepth, redoDepth } from '@codemirror/commands';
import { languages } from '@codemirror/language-data';
import { css } from '@codemirror/lang-css';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { html } from '@codemirror/lang-html';
import { autocompleteEmoji } from './extensions/autocompleteEmoji.js';
import { searchKeymap, search } from '@codemirror/search';
import { closeBrackets } from '@codemirror/autocomplete';
const autoCloseBrackets = closeBrackets({ brackets: ['()', '[]', '{{}}'] });
import defaultCM5Theme from '@themes/codeMirror/default.js';
import darkbrewery from '@themes/codeMirror/darkbrewery.js';
import cm5Themes from 'codemirror-5-themes';
const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const themeCompartment = new Compartment();
const highlightCompartment = new Compartment();
import { generalKeymap, markdownKeymap, cssKeymap, formatCSS } from './extensions/customKeyMaps.js';
import foldOnPages from './extensions/customFolding.js';
import { customHighlightStyle , customHighlightPlugin } from './extensions/customHighlight.js';
import { legacyCustomHighlightStyle } from './extensions/legacyCustomHighlight.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const setProgrammaticCursorLine = StateEffect.define();
const programmaticCursorLineField = StateField.define({
create() {
return Decoration.none;
},
update(decorations, transitionState) {
//deco is the decoratiions object
//tr is the transition state object, tr.effects is an array of stateEffects
//seems to be the easiest way of setting a class programatically only when called
for (const effects of transitionState.effects) {
if(effects.is(setProgrammaticCursorLine)) {
const pos = effects.value;
if(pos == null) return Decoration.none;
const line = transitionState.state.doc.lineAt(pos);
return Decoration.set([
Decoration.line({
class : 'sourceMoveFlash'
}).range(line.from)
]);
}
}
return decorations;
},
provide : (decorationSet)=>EditorView.decorations.from(decorationSet)
});
const CodeEditor = forwardRef(
(
{
language = '',
tab = 'brewText',
view,
value = '',
onChange = ()=>{},
onCursorChange = ()=>{},
onViewChange = ()=>{},
editorTheme = 'default',
style,
renderer,
...props
},
ref,
)=>{
const editorRef = useRef(null);
const viewRef = useRef(null);
const docsRef = useRef({});
const tabRef = useRef(tab);
const prevTabRef = useRef(tab);
const scrollRef = useRef({});
const foldsRef = useRef({});
const pageMap = useRef([]);
const recomputePages = (doc)=>{
if(tab !== 'brewText') return;
const pages = [0];
const text = doc.toString();
let offset = 0;
for (const line of text.split('\n')) {
if(PAGEBREAK_REGEX_V3.test(line)) {
pages.push(offset);
}
offset += line.length + 1;
}
pageMap.current = pages;
};
const findPageFromPos = (pos)=>{
const pages = pageMap.current;
let page = 1;
for (let i = 1; i < pages.length; i++) {
if(pos >= pages[i]) page = i + 1;
}
return page;
};
const getFoldRanges = (state)=>{
const folds = [];
state.field(foldState, false)?.between(0, state.doc.length, (from, to)=>{
folds.push({ from, to });
});
return folds;
};
const createExtensions = ({ onChange, language, editorTheme })=>{
const setEventListeners = EditorView.updateListener.of((update)=>{
if(update.docChanged) {
recomputePages(update.state.doc);
onChange(update.state.doc.toString());
}
if(update.selectionSet) {
const pos = update.state.selection.main.head;
const page = findPageFromPos(pos);
onCursorChange(page);
}
});
const highlightExtension = renderer === 'V3'
? syntaxHighlighting(customHighlightStyle)
: syntaxHighlighting(legacyCustomHighlightStyle);
const languageExtension = language === 'css' ? css() : [markdown({ base: markdownLanguage, codeLanguages: languages }), html({ autoCloseTags: true })];
const themeExtension = Array.isArray(themes[editorTheme]) ? themes[editorTheme] : themes[editorTheme] || themes['default'];
return [
EditorView.lineWrapping,
setEventListeners,
languageExtension,
autoCloseBrackets,
lineNumbers(),
scrollPastEnd(),
search(),
history(), //allows for undo and redo
...(tab !== 'brewStyles' ? [autocompleteEmoji] : []),
//folding
foldOnPages,
foldGutter({
openText : '▾',
closedText : '▸'
}),
//highlights
highlightCompartment.of([customHighlightPlugin(renderer, tab), highlightExtension]),
themeCompartment.of(themeExtension),
highlightActiveLine(),
highlightActiveLineGutter(),
//keyboard shortcut
keymap.of([...defaultKeymap, foldKeymap, ...searchKeymap]),
generalKeymap,
...(tab === 'brewStyles' ? [cssKeymap] : [markdownKeymap]),
//multiple cursors and selections
drawSelection(),
rectangularSelection(),
crosshairCursor(),
EditorState.allowMultipleSelections.of(true),
dropCursor(),
programmaticCursorLineField,
];
};
useEffect(()=>{
if(!editorRef.current) return;
const state = EditorState.create({
doc : value,
extensions : createExtensions({ onChange, language, editorTheme }),
});
recomputePages(state.doc);
viewRef.current = new EditorView({
state,
parent : editorRef.current,
});
const view = viewRef.current;
let ticking = false;
const handleScroll = ()=>{
if(ticking) return;
ticking = true;
requestAnimationFrame(()=>{
const top = view.scrollDOM.scrollTop;
scrollRef.current[tabRef.current] = top;
const block = view.lineBlockAtHeight(top);
const page = findPageFromPos(block.from);
onViewChange(page);
ticking = false;
});
};
view.scrollDOM.addEventListener('scroll', handleScroll);
docsRef.current[tab] = state;
return ()=>{
view.scrollDOM.removeEventListener('scroll', handleScroll);
viewRef.current?.destroy();
};
}, []);
const restoreFolds = (view, folds)=>{
if(!folds?.length) return;
view.dispatch({
effects : folds.map((f)=>foldEffect.of(f))
});
};
useEffect(()=>{
const view = viewRef.current;
if(!view) return;
tabRef.current = tab;
const prevTab = prevTabRef.current;
foldsRef.current[prevTab] = getFoldRanges(view.state);
if(prevTab !== tab) {
docsRef.current[prevTab] = view.state;
let nextState = docsRef.current[tab];
if(!nextState) {
nextState = EditorState.create({
doc : value,
extensions : createExtensions({ onChange, language, editorTheme }),
});
}
view.setState(nextState);
restoreFolds(view, foldsRef.current[tab]);
const savedScroll = scrollRef.current[tab];
if(savedScroll != null) {
requestAnimationFrame(()=>{
view.scrollDOM.scrollTop = savedScroll;
});
}
prevTabRef.current = tab;
}
view.focus();
}, [tab]);
useEffect(()=>{
const view = viewRef.current;
if(!view) return;
const current = view.state.doc.toString();
if(value !== current) {
view.dispatch({
changes : { from: 0, to: current.length, insert: value },
});
}
}, [value]);
useEffect(()=>{
//rebuild theme extension on theme change
const view = viewRef.current;
if(!view) return;
const themeExtension = Array.isArray(themes[editorTheme])? themes[editorTheme]: themes[editorTheme] || themes['default'];
view.dispatch({
effects : themeCompartment.reconfigure(themeExtension),
});
}, [editorTheme, tab]);
useEffect(()=>{
//rebuild syntax highlight when changing tab or renderer
const view = viewRef.current;
if(!view) return;
const highlightExtension =renderer === 'V3'
? syntaxHighlighting(customHighlightStyle)
: syntaxHighlighting(legacyCustomHighlightStyle);
view.dispatch({
effects : highlightCompartment.reconfigure([customHighlightPlugin(renderer, tab), highlightExtension]),
});
}, [renderer, tab]);
useImperativeHandle(ref, ()=>({
injectText : (text)=>{
const view = viewRef.current;
view.dispatch(
view.state.replaceSelection(text)
);
view.focus();
},
getCursorPosition : ()=>viewRef.current.state.selection.main.head,
scrollToPage : (pageNumber, smooth = true)=>{
const view = viewRef.current;
if(!view) return;
const pos = pageMap.current[pageNumber - 1] ?? 0;
view.dispatch({
selection : { anchor: pos },
effects : [setProgrammaticCursorLine.of(pos), EditorView.scrollIntoView(pos, { y: 'start' })],
});
view.focus();
setTimeout(()=>{
view.dispatch({
effects : setProgrammaticCursorLine.of(null)
});
}, 400);
},
formatCode : ()=>formatCSS(viewRef.current),
undo : ()=>undo(viewRef.current),
redo : ()=>redo(viewRef.current),
historySize : ()=>{
const view = viewRef.current;
if(!view) return { done: 0, undone: 0 };
return {
done : undoDepth(view.state),
undone : redoDepth(view.state),
};
},
foldAll : ()=>{
const view = viewRef.current;
if(!view) return;
const doc = view.state.doc;
const pages = pageMap.current;
const effects = pages.map((start, i)=>{
const next = pages[i + 1] || doc.length;
const from = i ? doc.line(doc.lineAt(start).number + 1).from : 0;
const to = doc.line(doc.lineAt(next).number).from - 1;
return to > from ? foldEffect.of({ from, to }) : null;
}).filter(Boolean);
view.dispatch({ effects });
},
unfoldAll : ()=>{
const view = viewRef.current;
if(!view) return;
view.dispatch(unfoldAllCmd(view));
},
focus : ()=>viewRef.current.focus(),
}));
return <div className={`codeEditor ${tab}`} ref={editorRef} style={style} />;
},
);
export default CodeEditor;
@@ -0,0 +1,240 @@
// Icon fonts for emoji/autocomplete
@import (less) '@themes/fonts/iconFonts/diceFont.less';
@import (less) '@themes/fonts/iconFonts/elderberryInn.less';
@import (less) '@themes/fonts/iconFonts/gameIcons.less';
@import (less) '@themes/fonts/iconFonts/fontAwesome.less';
@keyframes sourceMoveAnimation {
50% {
color : white;
background-color : red;
}
100% {
color : unset;
background-color : unset;
}
}
@keyframes slideacross {
0% {
bottom: -200px;
left: -200px;
}
100% {
bottom:100%;
left:100%;
}
}
:where(.codeEditor) {
width : 100%;
height : calc(100% - 25px);
font-family : monospace;
.cm-editor {
height : 100%;
outline : none !important;
&.cm-flash {
position:relative;
&::after {
position:absolute;
content:'';
bottom:-200px;
left:-200px;
translate:-50%;
width:200%;
height:100px;
background:linear-gradient(0deg, #89eafc00 0px, #89ebfc8e 50px, #89eafc00 100px, transparent);
display:block;
rotate:30deg;
@media screen and (prefers-reduced-motion: no-preference) {
animation: .5s linear 1 slideacross ;
}
}
}
}
&.brewSnippets .cm-snippetLine,
:where(&.brewText) .cm-pageLine {
background : #33333328;
border-top : #333399 solid 1px;
}
&.brewSnippets {
.cm-pageLine {
color : #777777;
background : #3E4E3E1B;
border-top : #3399423B solid 1px;
}
}
&:where(.brewText), &.brewSnippets {
.cm-pageLine[data-page-number]::after {
float : right;
color : grey;
content : attr(data-page-number);
}
.cm-columnSplit {
font-style : italic;
color : grey;
background-color : fade(#229999, 15%);
border-bottom : #229999 solid 1px;
}
.cm-define {
&:not(.term):not(.definition) {
font-weight : bold;
color : #949494;
background : #E5E5E5;
border-radius : 3px;
}
&.term { color : rgb(96, 117, 143); }
&.definition { color : rgb(97, 57, 178); }
}
.cm-block:not(.cm-comment),
.cm-block:not(.cm-comment) * {
font-weight : bold;
color : purple;
}
.cm-inline-block,
.cm-define .cm-inline-block {
font-weight : bold;
color : red;
span:not(.cm-comment) { color : inherit; }
}
.cm-injection:not(.cm-comment) {
font-weight : bold;
color : green;
span { color : inherit; }
}
.cm-emoji:not(.cm-comment) {
padding-bottom : 1px;
margin-left : 2px;
font-weight : bold;
color : #360034;
outline : solid 2px #FF96FC;
outline-offset : -2px;
background : #FFC8FF;
border-radius : 6px;
}
.cm-superscript:not(.cm-comment) {
font-size : 0.9em;
font-weight : bold;
vertical-align : super;
color : goldenrod;
}
.cm-subscript:not(.cm-comment) {
font-size : 0.9em;
font-weight : bold;
vertical-align : sub;
color : rgb(123, 123, 15);
}
.cm-strikethrough {
text-decoration: line-through;
}
.cm-definitionList {
.cm-definitionTerm { color : rgb(96, 117, 143); }
.cm-definitionColon:not(:has(.cm-comment)) {
font-weight : bold;
color : #949494;
background : #E5E5E5;
border-radius : 3px;
}
.cm-definitionDesc { color : rgb(97, 57, 178); }
}
.cm-tooltip-autocomplete {
li {
display : flex;
gap : 10px;
align-items : center;
justify-content : flex-start;
.cm-completionIcon { display : none; }
.cm-tooltip-autocomplete .cm-completionLabel { translate : 0 -2px; }
}
}
}
.cm-content { tab-size : 2 !important; }
@media screen and (pointer : coarse) {
font-size : 16px;
}
.cm-gutterElement span {
font-family : inherit;
font-weight : 600;
color : grey;
text-shadow : none;
}
.cm-foldGutter {
cursor : pointer;
border-left : 1px solid #EEEEEE;
transition : background 0.1s;
&:hover { background : #DDDDDD; }
}
/* Flash animation for source moves */
.cm-line.sourceMoveFlash {
animation-name : sourceMoveAnimation;
animation-duration : 0.4s;
}
/* Search input */
.cm-searchField {
width : 25em !important;
outline : 1px inset #00000055 !important;
}
.cm-image {
position:relative;
.cm-preview {
object-fit: contain;
position:absolute;
bottom:0;
left:0;
height:200px;
width:200px;
height:200px;
padding:5px;
background-color: #fff;
border-radius:10px;
border:3px solid grey;
pointer-events: none;
opacity:0;
transition:0.2s opacity 0.5s;
translate:0 100%;
z-index:1000;
}
&:hover .cm-preview {
opacity:1;
}
}
/* Tab character visualization (optional) */
//.cm-tab {
// background: url(...) no-repeat right;
//}
/* Trailing space visualization (optional) */
//.cm-trailingSpace .cm-space {
// background: url(...) no-repeat right;
//}
}
/* Emoji preview styling */
.emojiPreview {
font-size : 1.5em;
line-height : 1.2em;
}
@@ -0,0 +1,76 @@
import { autocompletion } from '@codemirror/autocomplete';
import diceFont from '@themes/fonts/iconFonts/diceFont.js';
import elderberryInn from '@themes/fonts/iconFonts/elderberryInn.js';
import fontAwesome from '@themes/fonts/iconFonts/fontAwesome.js';
import gameIcons from '@themes/fonts/iconFonts/gameIcons.js';
const emojis = {
...diceFont,
...elderberryInn,
...fontAwesome,
...gameIcons
};
const emojiCompletionList = (context)=>{
const word = context.matchBefore(/:[^\s:]+/);
if(!word) return null;
const line = context.state.doc.lineAt(context.pos);
const textToCursor = line.text.slice(0, context.pos - line.from);
if(textToCursor.includes('{')) {
const curlyToCursor = textToCursor.slice(textToCursor.indexOf('{'));
const curlySpanRegex = /{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1$/g;
if(curlySpanRegex.test(curlyToCursor)) return null;
}
const currentWord = word.text.slice(1); // remove ':'
const options = Object.keys(emojis)
.filter((e)=>e.toLowerCase().includes(currentWord.toLowerCase()))
.sort((a, b)=>{
const normalize = (str)=>str.replace(/\d+/g, (m)=>m.padStart(4, '0')).toLowerCase();
return normalize(a) < normalize(b) ? -1 : 1;
})
.map((e)=>({
label : e,
apply : `${e}:`,
type : 'text',
info : ()=>{
const div = document.createElement('div');
div.innerHTML = `<i class="emojiPreview ${emojis[e]}"></i> ${e}`;
return div;
}
}));
//Label is the text in the list, comes with an icon that just
//renders example text "abc", hid that with css because i didn't see other choice
//Apply is the text that is set when the choice is selected
//Info is the tooltip
return {
from : word.from + 1,
options,
filter : false,
};
};
export const autocompleteEmoji = autocompletion({
override : [emojiCompletionList],
activateOnTyping : true,
addToOptions : [
{
render(completion) {
const e = completion.label;
const icon = document.createElement('i');
icon.className = `emojiPreview ${emojis[e]}`;
const fragment = document.createDocumentFragment();
fragment.appendChild(icon);
return fragment;
}
}
]
});
@@ -0,0 +1,46 @@
import { foldService, codeFolding } from '@codemirror/language';
const foldOnPages = [
foldService.of((state, lineStart)=>{ //tells where to fold
const doc = state.doc;
const matcher = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const startLine = doc.lineAt(lineStart);
const prevLineText = startLine.number > 1 ? doc.line(startLine.number - 1).text : '';
if(!matcher.test(prevLineText)) return null;
let endLine = startLine.number;
while (endLine < doc.lines && !matcher.test(doc.line(endLine + 1).text)) {
endLine++;
}
if(endLine === startLine.number) return null;
return { from: startLine.from, to: doc.line(endLine).to };
}),
codeFolding({
preparePlaceholder : (state, range)=>{
const doc = state.doc;
const start = doc.lineAt(range.from).number;
const end = doc.lineAt(range.to).number;
if(doc.line(start).text.trim()) return ` ↤ Lines ${start}-${end}`;
const preview = Array.from({ length: end - start }, (_, i)=>doc.line(start + 1 + i).text.trim()
).find(Boolean) || `Lines ${start}-${end}`;
return `${preview.replace('{', '').slice(0, 50).trim()}${preview.length > 50 ? '...' : ''}`;
},
placeholderDOM(view, onclick, prepared) {
const span = document.createElement('span');
span.className = 'cm-fold-placeholder';
span.textContent = prepared;
span.onclick = onclick;
span.style.color = '#989898';
return span;
},
}),
];
export default foldOnPages;
@@ -0,0 +1,471 @@
/* eslint max-lines: ["error", { "max": 500 }] */
import { HighlightStyle } from '@codemirror/language';
import { tags } from '@lezer/highlight';
import { legacyTokenizeCustomMarkdown } from './legacyCustomHighlight';
import {
Decoration,
ViewPlugin,
} from '@codemirror/view';
import {
syntaxTree,
ensureSyntaxTree
} from '@codemirror/language';
// Making the tokens
const customTags = {
pageLine : 'pageLine', // .cm-pageLine
snippetLine : 'snippetLine', // .cm-snippetLine
columnSplit : 'columnSplit', // .cm-columnSplit
block : 'block', // .cm-block
inlineBlock : 'inline-block', // .cm-inline-block
injection : 'injection', // .cm-injection
emoji : 'emoji', // .cm-emoji
superscript : 'superscript', // .cm-superscript
subscript : 'subscript', // .cm-subscript
definitionList : 'definitionList', // .cm-definitionList
definitionTerm : 'definitionTerm', // .cm-definitionTerm
definitionDesc : 'definitionDesc', // .cm-definitionDesc
definitionColon : 'definitionColon', // .cm-definitionColon
strikethrough : 'strikethrough', // .cm-strikethrough
//CSS
variable : 'variable',
};
function tokenizeCustomMarkdown(text) {
const tokens = [];
const lines = text.split('\n');
//tokens without a `from` or `to` are interpreted by the custom plugin as line tokens
lines.forEach((lineText, lineNumber)=>{
// --- Page / snippet lines ---
if(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m.test(lineText)) tokens.push({ line: lineNumber, type: customTags.pageLine });
if(/^\\snippet\ .*$/.test(lineText)) tokens.push({ line: lineNumber, type: customTags.snippetLine });
if(/^\\column(?:break)?$/.test(lineText)) tokens.push({ line: lineNumber, type: customTags.columnSplit });
// --- Emoji ---
if(/:.\w+?:/.test(lineText)) {
const emojiRegex = /(:\w+?:)/g;
let match;
while ((match = emojiRegex.exec(lineText)) !== null) {
tokens.push({
line : lineNumber,
type : customTags.emoji,
from : match.index,
to : match.index + match[0].length,
});
}
}
// --- Superscript / Subscript ---
if(/\^/.test(lineText)) {
let startIndex = lineText.indexOf('^');
const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy;
const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy;
while (startIndex >= 0) {
superRegex.lastIndex = subRegex.lastIndex = startIndex;
let match = subRegex.exec(lineText);
let type = customTags.subscript;
if(!match) {
match = superRegex.exec(lineText);
type = customTags.superscript;
}
if(match) {
tokens.push({
line : lineNumber,
type,
from : match.index,
to : match.index + match[0].length,
});
}
startIndex = lineText.indexOf(
'^',
Math.max(startIndex + 1, superRegex.lastIndex || 0, subRegex.lastIndex || 0),
);
}
}
// --- Strikethrough ---
if(/\~/.test(lineText)) {
const strikethroughRegex = /~(?!\s)(.+?)(?<!\s)~/g;
const match = strikethroughRegex.exec(lineText);
const type = customTags.strikethrough;
if(match) {
tokens.push({
line : lineNumber,
type,
from : match.index,
to : match.index + match[0].length,
});
}
}
// --- single line def list ---
const singleLineRegex = /^(?=.*[^:])(.+?)(\s*)(::)([^\n]*)$/dmy;
const match = singleLineRegex.exec(lineText);
if(match) {
const [full, term, spaces, colons, desc] = match;
let offset = 0;
tokens.push({
line : lineNumber,
type : customTags.definitionList,
});
// Term
tokens.push({
line : lineNumber,
type : customTags.definitionTerm,
from : offset,
to : offset + term.length,
});
offset += term.length;
// Spaces before ::
if(spaces) {
offset += spaces.length;
}
// :: colons
tokens.push({
line : lineNumber,
type : customTags.definitionColon,
from : offset,
to : offset + colons.length,
});
offset += colons.length;
// Definition
tokens.push({
line : lineNumber,
type : customTags.definitionDesc,
from : offset,
to : offset + desc.length,
});
}
// --- multiline def list ---
if(!/^::/.test(lines[lineNumber]) && lineNumber + 1 < lines.length && /^::/.test(lines[lineNumber + 1])) {
const startLine = lineNumber;
const defs = [];
// collect all following :: definitions
for (let i = lineNumber + 1; i < lines.length; i++) {
const nextLine = lines[i];
const onlyColonsMatch = /^:*$/.test(nextLine);
const defMatch = /^(::)(.+)$/.exec(nextLine);
if(!onlyColonsMatch && defMatch) {
defs.push({ colons: defMatch[1], desc: defMatch[2], line: i });
} else break;
}
if(defs.length > 0 && lineText.trim().length > 0) {
tokens.push({
line : startLine,
type : customTags.definitionList,
});
// term
tokens.push({
line : startLine,
type : customTags.definitionTerm,
from : 0,
to : lineText.length,
});
// definitions
defs.forEach((d)=>{
tokens.push({
line : d.line,
type : customTags.definitionList,
});
tokens.push({
line : d.line,
type : customTags.definitionColon,
from : 0,
to : d.colons.length,
});
tokens.push({
line : d.line,
type : customTags.definitionDesc,
from : d.colons.length,
to : d.colons.length + d.desc?.length,
});
});
}
}
if(lineText.includes('{') && lineText.includes('}')) {
const injectionRegex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gmd;
let match;
while ((match = injectionRegex.exec(lineText)) !== null) {
tokens.push({
line : lineNumber,
from : match.indices[1][0],
to : match.indices[1][1],
type : customTags.injection,
});
}
}
if(lineText.includes('{{') && lineText.includes('}}')) {
// Inline blocks: single-line {{…}}
const spanRegex = /{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *|}}/g;
let match;
let blockCount = 0;
while ((match = spanRegex.exec(lineText)) !== null) {
if(match[0].startsWith('{{')) {
blockCount += 1;
} else {
blockCount -= 1;
}
if(blockCount < 0) {
blockCount = 0;
continue;
}
tokens.push({
line : lineNumber,
from : match.index,
to : match.index + match[0].length,
type : customTags.inlineBlock,
});
}
} else if(lineText.trimLeft().startsWith('{{') || lineText.trimLeft().startsWith('}}')) {
// Highlight block divs {{\n Content \n}}
let endCh = lineText.length + 1;
const match = lineText.match(
/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/,
);
if(match) endCh = match.index + match[0].length;
const closingMatch = lineText.match(/ *(}})/d);
if(closingMatch) {
tokens.push({ line: lineNumber, from: closingMatch.indices[1][0], to: closingMatch.indices[1][1], type: customTags.block });
} else {
tokens.push({ line: lineNumber, type: customTags.block });
}
}
});
return tokens;
}
function tokenizeCustomCSS(text) {
const tokens = [];
const lines = text.split('\n');
lines.forEach((lineText, lineNumber)=>{
if(/--[a-zA-Z0-9-_]+/gm.test(lineText)) {
const varRegex =/--[a-zA-Z0-9-_]+/gm;
let match;
while ((match = varRegex.exec(lineText)) !== null) {
tokens.push({
line : lineNumber,
from : match.index +1,
to : match.index + match.length[1] +1,
type : customTags.varProperty,
});
}
}
});
return tokens;
}
//assign classes to tags provided by lezer, not unlike the function above
export const customHighlightStyle = HighlightStyle.define([
{ tag: tags.heading, class: 'cm-header' },
{ tag: tags.heading1, class: 'cm-header cm-header-1' },
{ tag: tags.heading2, class: 'cm-header cm-header-2' },
{ tag: tags.heading3, class: 'cm-header cm-header-3' },
{ tag: tags.heading4, class: 'cm-header cm-header-4' },
{ tag: tags.heading5, class: 'cm-header cm-header-5' },
{ tag: tags.heading6, class: 'cm-header cm-header-6' },
{ tag: tags.link, class: 'cm-link' },
{ tag: tags.string, class: 'cm-string' },
{ tag: tags.url, class: 'cm-string cm-url' },
{ tag: tags.list, class: 'cm-list' },
{ tag: tags.strong, class: 'cm-strong' },
{ tag: tags.emphasis, class: 'cm-em' },
{ tag: tags.quote, class: 'cm-quote' },
{ tag: tags.comment, class: 'cm-comment' },
{ tag: tags.monospace, class: 'cm-comment' },
//css tags
{ tag: tags.tagName, class: 'cm-tag' },
{ tag: tags.className, class: 'cm-class' },
{ tag: tags.propertyName, class: 'cm-property' },
{ tag: tags.attributeValue, class: 'cm-value' },
{ tag: tags.keyword, class: 'cm-keyword' },
{ tag: tags.atom, class: 'cm-atom' },
{ tag: tags.integer, class: 'cm-integer' },
{ tag: tags.unit, class: 'cm-unit' },
{ tag: tags.color, class: 'cm-color' },
{ tag: tags.paren, class: 'cm-paren' },
{ tag: tags.variableName, class: 'cm-variable' },
{ tag: tags.invalid, class: 'cm-error' },
]);
function getUrl(node, doc) {
let url = null;
const cursor = node.node.cursor();
if(cursor.firstChild()) {
do {
if(cursor.name === 'URL') {
url = doc.sliceString(cursor.from, cursor.to);
break;
}
} while (cursor.nextSibling());
}
return url;
}
import { WidgetType } from '@codemirror/view';
class ImageWidget extends WidgetType {
constructor(url) {
super();
this.url = url;
}
toDOM() {
const img = document.createElement('img');
img.loading = 'lazy';
img.className = 'cm-preview';
img.src = this.url;
img.onerror = ()=>{
img.src = 'client/icons/broken-image.jpg';
};
return img;
}
eq(other) {
return other.url === this.url;
}
}
export function customHighlightPlugin(renderer, tab) {
//this function takes the custom tokens created in the tokenize function in customhighlight files
//takes the tokens defined by that function and assigns classes to them
//it also creates page number and snippet number widgets
let tokenize;
if(tab === 'brewStyles') {
tokenize = tokenizeCustomCSS;
} else {
tokenize = renderer === 'V3' ? tokenizeCustomMarkdown : legacyTokenizeCustomMarkdown;
}
return ViewPlugin.fromClass(
class {
constructor(view) {
this.decorations = this.buildDecorations(view);
}
update(update) {
if(update.docChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const decos = [];
const tokens = tokenize(view.state.doc.toString());
let pageCount = 1;
let snippetCount = 0;
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 50) || syntaxTree(view.state);
tree.iterate({
enter : (node)=>{
if(node.name === 'Image') {
const url = getUrl(node, view.state.doc);
const widgetPosition = node.node.lastChild.from;
//this is not exactly standard, but should hold,
//and is the shortest way i could find of positioning
//the image inside the cm-image node
if(!url) return;
decos.push(
Decoration.mark({
class : 'cm-image'
}).range(node.from, node.to)
);
decos.push(
Decoration.widget({
widget : new ImageWidget(url),
side : 1
}).range(widgetPosition)
);
}
}
});
tokens.forEach((token)=>{
const line = view.state.doc.line(token.line + 1);
if(token.from != null && token.to != null && token.from < token.to) {
const from = line.from + token.from;
const to = line.from + token.to;
const attrs = {};
if(token.type === 'Image' && token.url) {
attrs['data-url'] = token.url;
}
decos.push(
Decoration.mark({
class : `cm-${token.type}`,
...(Object.keys(attrs).length
? { attributes: attrs }
: {})
}).range(from, to)
);
} else {
decos.push(
Decoration.line({
class : `cm-${token.type}`
}).range(line.from)
);
if(token.type === 'pageLine' && tab === 'brewText') {
pageCount++;
if(line.from === 0) pageCount--;
decos.push(Decoration.line({ attributes: { 'data-page-number': pageCount } }).range(line.from));
}
if(token.type === 'snippetLine' && tab === 'brewSnippets') {
snippetCount++;
decos.push(Decoration.line({ attributes: { 'data-page-number': snippetCount } }).range(line.from));
}
}
});
decos.sort((a, b)=>a.from - b.from || a.to - b.to);
return Decoration.set(decos);
}
},
{ decorations: (v)=>v.decorations }
);
};
@@ -0,0 +1,273 @@
/* eslint max-lines: ["error", { "max": 300 }] */
import { keymap } from '@codemirror/view';
import { undo, redo, indentMore, indentLess, deleteLine } from '@codemirror/commands';
import { EditorSelection } from '@codemirror/state';
import { Prec } from '@codemirror/state';
import * as prettier from 'prettier/standalone';
import * as postcssPlugin from 'prettier/plugins/postcss';
export async function formatCSS(view) {
try {
const { from, to, empty } = view.state.selection.main;
const fullDoc = view.state.doc.toString();
const selection = view.state.doc.sliceString(from, to);
const code = empty ? fullDoc : selection;
let formatted = await prettier.format(code, {
parser: 'css',
plugins: [postcssPlugin],
// formatting options
tabWidth: 2,
useTabs: false,
printWidth: 100,
singleQuote: false,
trailingComma: 'all',
bracketSpacing: true,
endOfLine: 'lf'
});
//format manually single declaration rules to span one line.
//Prettier can't do it by default, this is crude but it works
formatted = formatted.replace(
/([^{]+)\{\s*\n\s*([^;\n]+:[^;\n]+;)\s*\n\s*\}(\s*)/g,
(_, selector, decl, whitespace) =>
`${selector} { ${decl.trim()} }${whitespace}`
);
if(formatted === code) return true;
const dom = view.dom;
dom.classList.add('cm-flash');
setTimeout(()=>{
dom.classList.remove('cm-flash');
view.dispatch({
changes : {
from : empty ? 0 : from,
to : empty ? view.state.doc.length : to,
insert : formatted
}
});
}, 500);
} catch (err) {
console.error('Error formatting css: ', err);
}
return true;
}
const insertTab = (view)=>{
// If any selection spans multiple lines, delegates to CodeMirror's indentMore
// Otherwise inserts two spaces at each cursor/selection
const shouldIndent = view.state.selection.ranges.some((range)=>view.state.doc.lineAt(range.from).number !==
view.state.doc.lineAt(range.to).number
);
if(shouldIndent) return indentMore(view);
const changes = [];
for (const range of view.state.selection.ranges) {
changes.push({
from : range.from,
to : range.to,
insert : ' ' // Insert two spaces, not a tab char!
});
}
// Create a transaction so we can map old positions to
// their new positions after the edits are applied
const mappedChanges = view.state.update({ changes });
view.dispatch({
changes,
selection : EditorSelection.create(
view.state.selection.ranges.map((range)=>EditorSelection.cursor(
mappedChanges.changes.mapPos(range.from, -1) + 2
)
)
)
});
return true;
};
const wrapSelection = (prefix, suffix)=>(view)=>{
const changes = [];
for (const range of view.state.selection.ranges) {
const { from, to } = range;
const selected = view.state.doc.sliceString(from, to);
let text;
if(from === to) { text = prefix + suffix; } else if(selected.startsWith(prefix) && selected.endsWith(suffix)) {
text = selected.slice(prefix.length, -suffix.length);
} else {text = `${prefix}${selected}${suffix}`;}
changes.push({ from, to, insert: text });
}
view.dispatch({
changes
});
return true;
};
const makeNbsp = (view)=>{
const { from } = view.state.selection.main;
const prev2 = from >= 2
? view.state.doc.sliceString(from - 2, from)
: '';
const insert = (prev2 === ':>' || prev2 === '>>') ? '>' : ':>';
view.dispatch({
changes : { from, to: from, insert },
selection : { anchor: from + insert.length },
});
return true;
};
const makeSpace = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const match = selected.match(/^{{width:(\d+)% }}$/);
let newText = '{{width:10% }}';
if(match) {
const percent = Math.min(parseInt(match[1], 10) + 10, 100);
newText = `{{width:${percent}% }}`;
}
view.dispatch({ changes: { from, to, insert: newText } });
return true;
};
const removeSpace = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const match = selected.match(/^{{width:(\d+)% }}$/);
if(match) {
const percent = parseInt(match[1], 10) - 10;
const newText = percent > 0 ? `{{width:${percent}% }}` : '';
view.dispatch({ changes: { from, to, insert: newText } });
}
return true;
};
const makeSpan = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const text = selected.startsWith('{{') && selected.endsWith('}}')
? selected.slice(2, -2)
: `{{${selected}}}`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const makeDiv = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const text = selected.startsWith('{{') && selected.endsWith('}}')
? selected.slice(2, -2)
: `{{\n${selected}\n}}`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const makeComment = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const isHtmlComment = selected.startsWith('<!--') && selected.endsWith('-->');
const text = isHtmlComment
? selected.slice(4, -3)
: `<!-- ${selected} -->`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const makeLink = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to).trim();
const isLink = /^\[(.*)\]\((.*)\)$/.exec(selected);
const text = isLink ? `${isLink[1]} ${isLink[2]}` : `[${selected || 'alt text'}](url)`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const makeList = (type)=>(view)=>{
const { from, to } = view.state.selection.main;
const lines = [];
for (let l = from; l <= to; l++) {
const lineText = view.state.doc.line(l + 1).text;
lines.push(lineText);
}
const joined = lines.join('\n');
let newText;
if(type === 'UL') newText = joined.replace(/^/gm, '- ');
else newText = joined.replace(/^/gm, (m, i)=>`${i + 1}. `);
view.dispatch({ changes: { from, to, insert: newText } });
return true;
};
const makeHeader = (level)=>(view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const text = `${'#'.repeat(level)} ${selected}`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const newColumn = (view)=>{
const { from, to } = view.state.selection.main;
view.dispatch({ changes: { from, to, insert: '\n\\column\n\n' } });
return true;
};
const newPage = (view)=>{
const { from, to } = view.state.selection.main;
view.dispatch({ changes: { from, to, insert: '\n\\page\n\n' } });
return true;
};
export const generalKeymap = Prec.high(keymap.of([
{ key: 'Tab', run: insertTab }, //runs indentMore if multiple lines selected in a single selection
{ key: 'Shift-Tab', run: indentLess },
{ key: 'Mod-z', run: undo }, //it may be unnecessary
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-y', run: redo }, //user asked, so double keybind
{ key: 'Mod-d', run: deleteLine }, //annoyingly overrides "selectNextOccurrence" because users asked
]));
export const cssKeymap = Prec.highest(keymap.of([
{ key: 'Mod-Shift-f', run: formatCSS },
{ key: 'Alt-Shift-f', run: formatCSS },
]));
export const markdownKeymap = Prec.highest(keymap.of([
{ key: 'Mod-b', run: wrapSelection('**', '**') }, // makeBold
{ key: 'Mod-i', run: wrapSelection('*', '*') }, // makeItalic
{ key: 'Mod-u', run: wrapSelection('<u>', '</u>') }, // makeUnderline
{ key: 'Shift-Mod-=', run: wrapSelection('^', '^') }, // makeSuper
{ key: 'Mod-=', run: wrapSelection('^^', '^^') }, // makeSub
{ key: 'Mod-.', run: makeNbsp },
{ key: 'Shift-Mod-.', run: makeSpace },
{ key: 'Shift-Mod-,', run: removeSpace },
{ key: 'Mod-m', run: makeSpan },
{ key: 'Shift-Mod-m', run: makeDiv },
{ key: 'Mod-/', run: makeComment },
{ key: 'Mod-k', run: makeLink },
{ key: 'Mod-l', run: makeList('UL') },
{ key: 'Shift-Mod-l', run: makeList('OL') },
{ key: 'Shift-Mod-1', run: makeHeader(1) },
{ key: 'Shift-Mod-2', run: makeHeader(2) },
{ key: 'Shift-Mod-3', run: makeHeader(3) },
{ key: 'Shift-Mod-4', run: makeHeader(4) },
{ key: 'Shift-Mod-5', run: makeHeader(5) },
{ key: 'Shift-Mod-6', run: makeHeader(6) },
{ key: 'Mod-Enter', run: newPage },
{ key: 'Shift-Mod-Enter', run: newColumn },
]));
@@ -0,0 +1,54 @@
import { HighlightStyle } from '@codemirror/language';
import { tags } from '@lezer/highlight';
const customTags = {
pageLine : 'pageLine', // .cm-pageLine
snippetLine : 'snippetLine', // .cm-snippetLine
};
export function legacyTokenizeCustomMarkdown(text) {
const tokens = [];
const lines = text.split('\n');
lines.forEach((lineText, lineNumber)=>{
// --- Page / snippet lines ---
if(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m.test(lineText)) tokens.push({ line: lineNumber, type: customTags.pageLine });
if(/^\\snippet\ .*$/.test(lineText)) tokens.push({ line: lineNumber, type: customTags.snippetLine });
});
return tokens;
}
export const legacyCustomHighlightStyle = HighlightStyle.define([
{ tag: tags.heading, class: 'cm-header' },
{ tag: tags.heading1, class: 'cm-header cm-header-1' },
{ tag: tags.heading2, class: 'cm-header cm-header-2' },
{ tag: tags.heading3, class: 'cm-header cm-header-3' },
{ tag: tags.heading4, class: 'cm-header cm-header-4' },
{ tag: tags.heading5, class: 'cm-header cm-header-5' },
{ tag: tags.heading6, class: 'cm-header cm-header-6' },
{ tag: tags.link, class: 'cm-link' },
{ tag: tags.string, class: 'cm-string' },
{ tag: tags.url, class: 'cm-string cm-url' },
{ tag: tags.list, class: 'cm-list' },
{ tag: tags.strong, class: 'cm-strong' },
{ tag: tags.emphasis, class: 'cm-em' },
{ tag: tags.quote, class: 'cm-quote' },
//css tags
{ tag: tags.tagName, class: 'cm-tag' },
{ tag: tags.className, class: 'cm-class' },
{ tag: tags.propertyName, class: 'cm-property' },
{ tag: tags.attributeValue, class: 'cm-value' },
{ tag: tags.keyword, class: 'cm-keyword' },
{ tag: tags.atom, class: 'cm-atom' },
{ tag: tags.integer, class: 'cm-integer' },
{ tag: tags.unit, class: 'cm-unit' },
{ tag: tags.color, class: 'cm-color' },
{ tag: tags.paren, class: 'cm-paren' },
{ tag: tags.variableName, class: 'cm-variable' },
{ tag: tags.invalid, class: 'cm-error' },
{ tag: tags.comment, class: 'cm-comment' },
]);
+21 -9
View File
@@ -1,9 +1,9 @@
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
require('./combobox.less');
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import './combobox.less';
const Combobox = createClass({
const Combobox = createReactClass({
displayName : 'Combobox',
getDefaultProps : function() {
return {
@@ -11,14 +11,17 @@ const Combobox = createClass({
trigger : 'hover',
default : '',
placeholder : '',
tooltip : '',
autoSuggest : {
clearAutoSuggestOnClick : true,
suggestMethod : 'includes',
filterOn : [] // should allow as array to filter on multiple attributes, or even custom filter
},
valuePatterns : /.+/
};
},
getInitialState : function() {
this.dropdownRef = React.createRef();
return {
showDropdown : false,
value : '',
@@ -39,7 +42,7 @@ const Combobox = createClass({
},
handleClickOutside : function(e){
// Close dropdown when clicked outside
if(this.refs.dropdown && !this.refs.dropdown.contains(e.target)) {
if(this.dropdownRef.current && !this.dropdownRef.current.contains(e.target)) {
this.handleDropdown(false);
}
},
@@ -69,11 +72,14 @@ const Combobox = createClass({
return (
<div className='dropdown-input item'
onMouseEnter={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(true);} : undefined}
onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined}>
onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined}
{...(this.props.tooltip ? { 'data-tooltip-right': this.props.tooltip } : {})}>
<input
type='text'
onChange={(e)=>this.handleInput(e)}
value={this.state.value || ''}
title=''
pattern={this.props.valuePatterns}
placeholder={this.props.placeholder}
onBlur={(e)=>{
if(!e.target.checkValidity()){
@@ -82,6 +88,12 @@ const Combobox = createClass({
});
}
}}
onKeyDown={(e)=>{
if(e.key === 'Enter') {
e.preventDefault();
this.props.onEntry(e);
}
}}
/>
<i className='fas fa-caret-down'/>
</div>
@@ -117,7 +129,7 @@ const Combobox = createClass({
});
return (
<div className={`dropdown-container ${this.props.className}`}
ref='dropdown'
ref={this.dropdownRef}
onMouseLeave={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(false);} : undefined}>
{this.renderTextInput()}
{this.renderDropdown(dropdownChildren)}
@@ -126,4 +138,4 @@ const Combobox = createClass({
}
});
module.exports = Combobox;
export default Combobox;
+1
View File
@@ -10,6 +10,7 @@
position : absolute;
z-index : 100;
width : 100%;
height : max-content;
max-height : 200px;
overflow-y : auto;
background-color : white;
+125
View File
@@ -0,0 +1,125 @@
/**
* A dropdown menu component that uses the Anchor Positioning API to position the elements. It supports nested submenus as well.
* Anchor Positioning is now supported in all major browsers. A polyfill is conditionally loaded for older browsers.
*
* As-is, the menus will always open down aligned on left to trigger, submenus open to the right initially.
* If no space, menus will still open down, but aligned to the right of the trigger. Submenus will flip to the other side of the top menu.
* This could be customized either in more specific CSS, or as a `direction` prop on the component (in future iterations).
*
* @param {string} props.groupName - Name of the menu. Appears as the trigger text.
* @param {string} [props.icon] - Icon to display in the trigger.
* @param {string} [props.color] - Color class to add to the trigger.
* @param {string} [props.className] - Additional classes for the menu wrapper.
* @param {React.ReactNode} [props.customTrigger] - Custom element to use as a trigger.
* @param {React.ReactNode} [props.children] - Child elements to render in the menu.
* @returns {React.JSX.Element}
*/
import './dropdown.less';
import React, { useEffect, useId, useRef } from 'react';
import _ from 'lodash';
// use react context to keep track of the menu depth (menus in menus)
const MenuDepthContext = React.createContext(0);
const Dropdown = ({ groupName, className = null, icon, children, color = null, customTrigger, ...props })=>{
const reactId = useId();
const safeId = reactId.replace(/[^a-zA-Z0-9_-]/g, '');
const menuId = `${_.kebabCase(groupName)}-${safeId}-menu`;
const anchorName = `--${menuId}`;
const depth = React.useContext(MenuDepthContext);
// A menu is a submenu if depth > 0
const isSubMenu = depth > 0;
const triggerRef = useRef(null);
const menuRef = useRef(null);
// use setAttribute instead of the React style prop because React strips unknown CSS
// properties (like anchor-name) from inline styles in browsers that don't support them.
// setAttribute writes raw CSS text that the anchor positioning polyfill can read
useEffect(()=>{
triggerRef.current?.setAttribute('style', `anchor-name: ${anchorName}`);
menuRef.current?.setAttribute('style', `position-anchor: ${anchorName}`);
}, [anchorName]);
// hide popover with click inside iframe (not supported by light dismiss)
useEffect(()=>{
const menuElement = document.getElementById(menuId);
if(!menuElement) return;
const handleClick = ()=>{
if(menuElement.matches(':popover-open')) {
menuElement.hidePopover();
}
};
// Listen for clicks from both the main document and the iframe
document.addEventListener('iframe-click', handleClick);
return ()=>{
document.removeEventListener('iframe-click', handleClick);
};
}, [menuId]);
// the trigger is the piece placed inside the opening button of the menu.
// This method allows for creating a generic span with the group name,
// or using a bespoke element (like a graphic) passed in from props to be used as the trigger
const trigger = (groupName = 'menu', icon = '')=>{
if(!customTrigger){
return <>
<i className={icon}></i><span className='menu-name'>{groupName}</span><i className={`caret fas fa-caret-${isSubMenu ? 'right' : 'down'}`}></i>
</>;
} else {
return customTrigger;
}
};
// handle clicks on menu items. By default, actions do dismiss.
const handleMenuActionClick = (event)=>{
const menuElement = menuRef.current;
if(!menuElement) return;
const menuAction = event.target.closest('button, a, [role="menuitem"]');
if(!menuAction || !menuElement.contains(menuAction)) return;
// don't dismiss if the target triggers a submenu
if(menuAction.hasAttribute('popovertarget')) return;
// don't dismiss if the target has `no-dismiss` attribute
const noDismissValue = menuAction.getAttribute('no-dismiss')?.toLowerCase();
if(noDismissValue === '' || noDismissValue === 'true') return;
document.querySelectorAll('.menu-list:popover-open').forEach((openMenu)=>openMenu.hidePopover());
};
return (
<li className='menu-wrapper' role='none'>
<button
id={`${menuId}-trigger`}
className={['menu-item', color].join(' ')}
popoverTarget={menuId}
aria-haspopup='menu'
aria-label={groupName}
role='menuitem'
disabled={!React.Children.count(children)}
ref={triggerRef}
>
{trigger(groupName, icon)}
</button>
<MenuDepthContext.Provider value={depth + 1}>
<ul
ref={menuRef}
id={menuId}
className='menu-list'
popover='auto'
role='menu'
aria-label={`${groupName} Submenu`}
onClick={handleMenuActionClick}
>
{children}
</ul>
</MenuDepthContext.Provider>
</li>
);
};
export { Dropdown };
+33
View File
@@ -0,0 +1,33 @@
@property --menuColor {
syntax: '<color>';
inherits: true;
initial-value: #DDD;
}
@property --activeTriggerColor {
syntax: '<color>';
inherits: true;
initial-value: #DDD;
}
:root{
--activeTriggerColor : var(--activeTriggerColor);
}
.menu-list {
contain : content;
position : fixed;
top : anchor(bottom);
left : anchor(left);
position-try: flip-inline flip-block;
color: inherit; // [popover] gets a `canvastext` color value from useragent.
background: var(--menuColor);
li > .menu-list {
margin: 0 0px;
top : anchor(top);
left : anchor(right);
position-try: flip-inline;
}
}
.menu-wrapper:has(:popover-open) > button { // if menu is open...
background-color: var(--activeTriggerColor, hsl(from var(--menuColor) h s calc(l * .85))); // tint menu triggers based on menu color
}
@@ -1,11 +1,11 @@
require('./renderWarnings.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
import './renderWarnings.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import Dialog from '../../../client/components/dialog.jsx';
import Dialog from '../dialog.jsx';
const RenderWarnings = createClass({
const RenderWarnings = createReactClass({
displayName : 'RenderWarnings',
getInitialState : function() {
return {
@@ -25,7 +25,7 @@ const RenderWarnings = createClass({
if(!isChrome){
return <li key='chrome'>
<em>Built for Chrome </em> <br />
Other browsers have not been tested for compatiblilty. If you
Other browsers have not been tested for compatibility. If you
experience issues with your document not rendering or printing
properly, please try using the latest version of Chrome before
submitting a bug report.
@@ -57,4 +57,4 @@ const RenderWarnings = createClass({
}
});
module.exports = RenderWarnings;
export default RenderWarnings;
@@ -1,3 +1,5 @@
@import '@sharedStyles/colors.less';
.renderWarnings {
position : relative;
float : right;
@@ -1,8 +1,8 @@
require('./splitPane.less');
const React = require('react');
const { useState, useEffect } = React;
import './splitPane.less';
import React, { useEffect, useState } from 'react';
const storageKey = 'naturalcrit-pane-split';
const PANE_WIDTH_KEY = 'HB_editor_splitWidth';
const LIVE_SCROLL_KEY = 'HB_editor_liveScroll';
const SplitPane = (props)=>{
const {
@@ -18,9 +18,8 @@ const SplitPane = (props)=>{
const [liveScroll, setLiveScroll] = useState(false);
useEffect(()=>{
const savedPos = window.localStorage.getItem(storageKey);
setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2);
setLiveScroll(window.localStorage.getItem('liveScroll') === 'true');
handleResize();
setLiveScroll(window.localStorage.getItem(LIVE_SCROLL_KEY) === 'true');
window.addEventListener('resize', handleResize);
return ()=>window.removeEventListener('resize', handleResize);
@@ -29,13 +28,16 @@ const SplitPane = (props)=>{
const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x)));
//when resizing, the divider should grow smaller if less space is given, then grow back if the space is restored, to the original position
const handleResize = ()=>setDividerPos(limitPosition(window.localStorage.getItem(storageKey), 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)));
const handleResize = ()=>{
const savedPos = window.localStorage.getItem(PANE_WIDTH_KEY);
setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2);
};
const handleUp =(e)=>{
e.preventDefault();
if(isDragging) {
onDragFinish(dividerPos);
window.localStorage.setItem(storageKey, dividerPos);
window.localStorage.setItem(PANE_WIDTH_KEY, dividerPos);
}
setIsDragging(false);
};
@@ -52,7 +54,7 @@ const SplitPane = (props)=>{
};
const liveScrollToggle = ()=>{
window.localStorage.setItem('liveScroll', String(!liveScroll));
window.localStorage.setItem(LIVE_SCROLL_KEY, String(!liveScroll));
setLiveScroll(!liveScroll);
};
@@ -107,4 +109,4 @@ const Pane = ({ width, children, isDragging, moveBrew, moveSource, liveScroll, s
);
};
module.exports = SplitPane;
export default SplitPane;
@@ -1,3 +1,4 @@
@import '@sharedStyles/core.less';
.splitPane {
position : relative;
@@ -1,7 +1,6 @@
const React = require('react');
const createClass = require('create-react-class');
import React from 'react';
module.exports = function(props){
export default function(props){
return <svg version='1.1' x='0px' y='0px' viewBox='0 0 90 112.5' enableBackground='new 0 0 90 90' >
<path d='M25.363,25.54c0,1.906,8.793,3.454,19.636,3.454c10.848,0,19.638-1.547,19.638-3.454c0-1.12-3.056-2.117-7.774-2.75 c-1.418,1.891-3.659,3.133-6.208,3.133c-2.85,0-5.315-1.547-6.67-3.833C33.617,22.185,25.363,23.692,25.363,25.54z'/><path d='M84.075,54.142c0-8.68-2.868-17.005-8.144-23.829c1.106-1.399,1.41-2.771,1.41-3.854c0-6.574-10.245-9.358-19.264-10.533 c0.209,0.706,0.359,1.439,0.359,2.215c0,0.09-0.022,0.17-0.028,0.26l0,0c-0.028,0.853-0.195,1.667-0.479,2.429 c9.106,1.282,14.508,3.754,14.508,5.63c0,2.644-10.688,6.486-27.439,6.486c-16.748,0-27.438-3.842-27.438-6.486 c0-2.542,9.904-6.183,25.559-6.459c-0.098-0.396-0.159-0.807-0.2-1.223c0.006,0,0.013,0,0.017,0 c-0.017-0.213-0.063-0.417-0.063-0.636c0-1.084,0.226-2.119,0.628-3.058c-6.788,0.129-30.846,1.299-30.846,11.376 c0,1.083,0.305,2.455,1.411,3.854c-5.276,6.823-8.145,15.149-8.145,23.829c0,11.548,5.187,20.107,14.693,25.115 c-0.902,3.146-1.391,7.056,1.111,8.181c2.626,1.178,5.364-2.139,7.111-5.005c4.73,1.261,10.13,1.923,16.161,1.923 c6.034,0,11.428-0.661,16.158-1.922c1.75,2.865,4.493,6.18,7.112,5.004c2.504-1.123,2.014-5.035,1.113-8.179 C78.889,74.249,84.075,65.689,84.075,54.142z M70.39,31.392c5.43,6.046,8.78,14,8.78,22.75c0,20.919-18.582,25.309-34.171,25.309 c-15.587,0-34.17-4.39-34.17-25.309c0-8.75,3.35-16.7,8.781-22.753c5.561,2.643,15.502,4.009,25.389,4.009 C54.886,35.397,64.829,34.031,70.39,31.392z'/><path d='M50.654,23.374c2.892,0,5.234-2.341,5.234-5.233c0-2.887-2.343-5.23-5.234-5.23c-2.887,0-5.231,2.343-5.231,5.23 C45.423,21.032,47.768,23.374,50.654,23.374z'/>
<circle cx='62.905' cy='10.089' r='3.595'/>
@@ -1,6 +1,5 @@
const React = require('react');
const createClass = require('create-react-class');
import React from 'react';
module.exports = function(props){
export default function(props){
return <svg version='1.1' x='0px' y='0px' viewBox='0 0 100 100' enableBackground='new 0 0 100 100'><path d='M80.644,87.982l16.592-41.483c0.054-0.128,0.088-0.26,0.108-0.394c0.006-0.039,0.007-0.077,0.011-0.116 c0.007-0.087,0.008-0.174,0.002-0.26c-0.003-0.046-0.007-0.091-0.014-0.137c-0.014-0.089-0.036-0.176-0.063-0.262 c-0.012-0.034-0.019-0.069-0.031-0.103c-0.047-0.118-0.106-0.229-0.178-0.335c-0.004-0.006-0.006-0.012-0.01-0.018L67.999,3.358 c-0.01-0.013-0.003-0.026-0.013-0.04L68,3.315V4c0,0-0.033,0-0.037,0c-0.403-1-1.094-1.124-1.752-0.976 c0,0.004-0.004-0.012-0.007-0.012C66.201,3.016,66.194,3,66.194,3H66.19h-0.003h-0.003h-0.004h-0.003c0,0-0.004,0-0.007,0 s-0.003-0.151-0.007-0.151L20.495,15.227c-0.025,0.007-0.046-0.019-0.071-0.011c-0.087,0.028-0.172,0.041-0.253,0.083 c-0.054,0.027-0.102,0.053-0.152,0.085c-0.051,0.033-0.101,0.061-0.147,0.099c-0.044,0.036-0.084,0.073-0.124,0.113 c-0.048,0.048-0.093,0.098-0.136,0.152c-0.03,0.039-0.059,0.076-0.085,0.117c-0.046,0.07-0.084,0.145-0.12,0.223 c-0.011,0.023-0.027,0.042-0.036,0.066L2.911,57.664C2.891,57.715,3,57.768,3,57.82v0.002c0,0.186,0,0.375,0,0.562 c0,0.004,0,0.004,0,0.008c0,0,0,0,0,0.002c0,0,0,0,0,0.004v0.004v0.002c0,0.074-0.002,0.15,0.012,0.223 C3.015,58.631,3,58.631,3,58.633c0,0.004,0,0.004,0,0.008c0,0,0,0,0,0.002c0,0,0,0,0,0.004v0.004c0,0,0,0,0,0.002v0.004 c0,0.191-0.046,0.377,0.06,0.545c0-0.002-0.03,0.004-0.03,0.004c0,0.004-0.03,0.004-0.03,0.004c0,0.002,0,0.002,0,0.002 l-0.045,0.004c0.03,0.047,0.036,0.09,0.068,0.133l29.049,37.359c0.002,0.004,0,0.006,0.002,0.01c0.002,0.002,0,0.004,0.002,0.008 c0.006,0.008,0.014,0.014,0.021,0.021c0.024,0.029,0.052,0.051,0.078,0.078c0.027,0.029,0.053,0.057,0.082,0.082 c0.03,0.027,0.055,0.062,0.086,0.088c0.026,0.02,0.057,0.033,0.084,0.053c0.04,0.027,0.081,0.053,0.123,0.076 c0.005,0.004,0.01,0.008,0.016,0.01c0.087,0.051,0.176,0.09,0.269,0.123c0.042,0.014,0.082,0.031,0.125,0.043 c0.021,0.006,0.041,0.018,0.062,0.021c0.123,0.027,0.249,0.043,0.375,0.043c0.099,0,0.202-0.012,0.304-0.027l45.669-8.303 c0.057-0.01,0.108-0.021,0.163-0.037C79.547,88.992,79.562,89,79.575,89c0.004,0,0.004,0,0.004,0c0.021,0,0.039-0.027,0.06-0.035 c0.041-0.014,0.08-0.034,0.12-0.052c0.021-0.01,0.044-0.019,0.064-0.03c0.017-0.01,0.026-0.015,0.033-0.017 c0.014-0.008,0.023-0.021,0.037-0.028c0.14-0.078,0.269-0.174,0.38-0.285c0.014-0.016,0.024-0.034,0.038-0.048 c0.109-0.119,0.201-0.252,0.271-0.398c0.006-0.01,0.016-0.018,0.021-0.029c0.004-0.008,0.008-0.017,0.011-0.026 c0.002-0.004,0.003-0.006,0.005-0.01C80.627,88.021,80.635,88.002,80.644,87.982z M77.611,84.461L48.805,66.453l32.407-25.202 L77.611,84.461z M46.817,63.709L35.863,23.542l43.818,14.608L46.817,63.709z M84.668,40.542l8.926,5.952l-11.902,29.75 L84.668,40.542z M89.128,39.446L84.53,36.38l-6.129-12.257L89.128,39.446z M79.876,34.645L37.807,20.622L65.854,6.599L79.876,34.645 z M33.268,19.107l-6.485-2.162l23.781-6.487L33.268,19.107z M21.92,18.895l8.67,2.891L10.357,47.798L21.92,18.895z M32.652,24.649 l10.845,39.757L7.351,57.178L32.652,24.649z M43.472,67.857L32.969,92.363L8.462,60.855L43.472,67.857z M46.631,69.09l27.826,17.393 l-38.263,6.959L46.631,69.09z'></path></svg>;
};
+62 -35
View File
@@ -1,34 +1,39 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./brewRenderer.less');
const React = require('react');
const { useState, useRef, useMemo, useEffect } = React;
const _ = require('lodash');
import brewRendererStylesUrl from './brewRenderer.less?url';
import headerNavStylesUrl from './headerNav/headerNav.less?url';
import './brewRenderer.less';
import React, { useState, useRef, useMemo, useEffect } from 'react';
import _ from 'lodash';
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
import Markdown from 'naturalcrit/markdown.js';
const ErrorBar = require('./errorBar/errorBar.jsx');
const ToolBar = require('./toolBar/toolBar.jsx');
import MarkdownLegacy from '@shared/markdownLegacy.js';
import { hbfm } from 'hbmarkedwrapper';
import ErrorBar from './errorBar/errorBar.jsx';
import ToolBar from './toolBar/toolBar.jsx';
//TODO: move to the brew renderer
const RenderWarnings = require('homebrewery/renderWarnings/renderWarnings.jsx');
const NotificationPopup = require('./notificationPopup/notificationPopup.jsx');
const Frame = require('react-frame-component').default;
const dedent = require('dedent-tabs').default;
const { printCurrentBrew } = require('../../../shared/helpers.js');
import RenderWarnings from '@components/renderWarnings/renderWarnings.jsx';
import NotificationPopup from './notificationPopup/notificationPopup.jsx';
import Frame from 'react-frame-component';
import dedent from 'dedent';
import { printCurrentBrew } from '@shared/helpers.js';
import HeaderNav from './headerNav/headerNav.jsx';
import { safeHTML } from './safeHTML.js';
import safeHTML from './safeHTML.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m;
const COLUMNBREAK_REGEX_LEGACY = /\\column(:?break)?/m;
const PAGE_HEIGHT = 1056;
const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState';
const INITIAL_CONTENT = dedent`
<!DOCTYPE html><html><head>
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" />
<title>Rendered Brew Content</title>
<link href='/homebrew/bundle.css' type="text/css" rel='stylesheet' />
<base target=_blank>
<link href="${brewRendererStylesUrl}" rel="stylesheet" />
<link href="${headerNavStylesUrl}" rel="stylesheet" />
<base target="_top">
</head><body style='overflow: hidden'><div></div></body></html>`;
@@ -37,10 +42,11 @@ const BrewPage = (props)=>{
props = {
contents : '',
index : 0,
hoisted : false,
...props
};
const pageRef = useRef(null);
const cleanText = safeHTML(`${props.contents}\n<div class="columnSplit"></div>\n`);
const cleanText = safeHTML(props.contents);
useEffect(()=>{
if(!pageRef.current) return;
@@ -86,6 +92,7 @@ const BrewPage = (props)=>{
//v=====--------------------< Brew Renderer Component >-------------------=====v//
let renderedPages = [];
let pageTemplates = [];
let rawPages = [];
const BrewRenderer = (props)=>{
@@ -122,7 +129,7 @@ const BrewRenderer = (props)=>{
//useEffect to store or gather toolbar state from storage
useEffect(()=>{
const toolbarState = JSON.parse(window.localStorage.getItem('hb_toolbarState'));
const toolbarState = JSON.parse(window.localStorage.getItem(TOOLBAR_STATE_KEY));
toolbarState && setDisplayOptions(toolbarState);
}, []);
@@ -130,6 +137,7 @@ const BrewRenderer = (props)=>{
const mainRef = useRef(null);
const pagesRef = useRef(null);
const urlRef = useRef('');
if(props.renderer == 'legacy') {
rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY);
@@ -195,13 +203,27 @@ const BrewRenderer = (props)=>{
return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;
} else {
if(pageText.startsWith('\\page')) {
const firstLineTokens = Markdown.marked.lexer(pageText.split('\n', 1)[0])[0].tokens;
const firstLineTokens = hbfm.marked.lexer(pageText.split('\n', 1)[0])[0].tokens;
const injectedTags = firstLineTokens?.find((obj)=>obj.injectedTags !== undefined)?.injectedTags;
if(injectedTags) {
styles = { ...styles, ...injectedTags.styles };
styles = _.mapKeys(styles, (v, k)=>k.startsWith('--') ? k : _.camelCase(k)); // Convert CSS to camelCase for React
classes = [classes, injectedTags.classes].join(' ').trim();
attributes = injectedTags.attributes;
if(global.enablev4) {
if(attributes && Object.hasOwn(attributes, 'hbtemplate')) {
pageTemplates[index] = attributes['hbtemplate'];
}
}
}
if(global.enablev4) {
// If we don't have a template for this page, look backwards until one is found or the first page.
if(!pageTemplates[index]) {
for (let i=index;i>=0; i--) {
// If one is found, add the template attribute
if(pageTemplates[i]) attributes['hbtemplate'] = pageTemplates[i];
}
}
}
pageText = pageText.includes('\n') ? pageText.substring(pageText.indexOf('\n') + 1) : ''; // Remove the \page line
}
@@ -209,28 +231,37 @@ const BrewRenderer = (props)=>{
// DO NOT REMOVE!!! REQUIRED FOR BACKWARDS COMPATIBILITY WITH NON-UPGRADABLE VERSIONS OF CHROME.
pageText += `\n\n&nbsp;\n\\column\n&nbsp;`; //Artificial column break at page end to emulate column-fill:auto (until `wide` is used, when column-fill:balance will reappear)
const html = Markdown.render(pageText, index);
const html = hbfm.render(pageText, index);
return <BrewPage className={classes} index={index} key={index} contents={html} style={styles} attributes={attributes} onVisibilityChange={handlePageVisibilityChange} />;
}
};
const renderPages = ()=>{
const renderPages = (checkHoists = false)=>{
if(props.errors && props.errors.length)
return renderedPages;
if(rawPages.length != renderedPages.length) // Re-render all pages when page count changes
if(rawPages.length != renderedPages.length) { // Re-render all pages when page count changes
renderedPages.length = 0;
pageTemplates.length = 0;
}
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first
if(rawPages.length > props.currentEditorCursorPageNum -1)
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
_.forEach(rawPages, (page, index)=>{
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
const varsOnPageRegex = /([!$]?)\[((?!\s*\])(?:\\.|[^\[\]\\])+)\]/g; // Find out if there are any vars on the page.
const forceRender = checkHoists &&
!props.hoisted &&
(page.match(varsOnPageRegex)); // forceRender forces pages outside of the PPR range to render if true.
// This is necessary on the first load to fully populate the variable table.
if((isInView(index) || !renderedPages[index] || forceRender) && typeof window !== 'undefined'){
renderedPages[index] = renderPage(page, index); // Render any page not yet rendered, but only re-render those in PPR range
}
});
if(!props.hoisted) { props.hoisted = true; } // Only fully hoist once.
return renderedPages;
};
@@ -267,8 +298,10 @@ const BrewRenderer = (props)=>{
const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount"
scrollToHash(window.location.hash);
window.addEventListener('hashchange', ()=>scrollToHash(window.location.hash));
setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame
renderPages(); //Make sure page is renderable before showing
renderPages(true); //Make sure page is renderable before showing
setState((prevState)=>({
...prevState,
isMounted : true,
@@ -284,7 +317,7 @@ const BrewRenderer = (props)=>{
const handleDisplayOptionsChange = (newDisplayOptions)=>{
setDisplayOptions(newDisplayOptions);
localStorage.setItem('hb_toolbarState', JSON.stringify(newDisplayOptions));
localStorage.setItem(TOOLBAR_STATE_KEY, JSON.stringify(newDisplayOptions));
};
const pagesStyle = {
@@ -293,12 +326,6 @@ const BrewRenderer = (props)=>{
rowGap : `${displayOptions.rowGap}px`
};
const styleObject = {};
if(global.config.deployment) {
styleObject.backgroundImage = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='40px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${global.config.deployment}</text></svg>")`;
}
const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]);
renderedPages = useMemo(()=>renderPages(), [props.text, displayOptions]);
@@ -322,15 +349,15 @@ const BrewRenderer = (props)=>{
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={state.visiblePages.length > 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/>
{/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
<Frame id='BrewRenderer' title="Rendered Brew Content" initialContent={INITIAL_CONTENT}
style={{ width: '100%', height: '100%', visibility: state.visibility }}
contentDidMount={frameDidMount}
onClick={()=>{emitClick();}}
sandbox="allow-same-origin allow-modals allow-top-navigation"
>
<div className={`brewRenderer ${global.config.deployment && 'deployment'}`}
<div className='brewRenderer'
onKeyDown={handleControlKeys}
tabIndex={-1}
style={ styleObject }
>
{/* Apply CSS from Style tab and render pages from Markdown tab */}
@@ -350,4 +377,4 @@ const BrewRenderer = (props)=>{
);
};
module.exports = BrewRenderer;
export default BrewRenderer;
@@ -1,4 +1,4 @@
@import (multiple, less) 'shared/naturalcrit/styles/reset.less';
@import '@sharedStyles/core.less';
.brewRenderer {
height : 100vh;
@@ -6,7 +6,6 @@
overflow-y : scroll;
will-change : transform;
&:has(.facing, .flow) { padding : 60px 30px; }
&.deployment { background-color : darkred; }
:where(.pages) {
&.facing {
display : grid;
@@ -60,6 +59,12 @@
}
&-corner { visibility : hidden; }
}
@supports (break-after:always) {
.columnSplit {
margin-bottom: 100vh;
}
}
}
.pane { position : relative; }
@@ -82,4 +87,5 @@
}
}
.headerNav { visibility : hidden; }
}
@@ -1,7 +1,7 @@
require('./errorBar.less');
const React = require('react');
import './errorBar.less';
import React from 'react';
import Dialog from '../../../components/dialog.jsx';
import Dialog from '@components/dialog.jsx';
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
@@ -50,4 +50,4 @@ const ErrorBar = (props)=>{
);
};
module.exports = ErrorBar;
export default ErrorBar;
@@ -1,3 +1,4 @@
@import '@sharedStyles/colors.less';
.errorBar {
position : absolute;
@@ -1,7 +1,7 @@
require('./headerNav.less');
import './headerNav.less';
import * as React from 'react';
import * as _ from 'lodash';
import React from 'react';
import _ from 'lodash';
const MAX_TEXT_LENGTH = 40;
@@ -104,7 +104,7 @@ const HeaderNavItem = ({ link, text, depth, className })=>{
if(!link || !text) return;
return <li>
<a href={`#${link}`} target='_self' className={`depth-${depth} ${className ?? ''}`}>
<a href={`#${link}`} className={`depth-${depth} ${className ?? ''}`}>
{trimString(text, depth)}
</a>
</li>;
@@ -1,9 +1,9 @@
require('./notificationPopup.less');
import './notificationPopup.less';
import React, { useEffect, useState } from 'react';
import request from '../../utils/request-middleware.js';
import Markdown from 'naturalcrit/markdown.js';
import { hbfm } from 'hbmarkedwrapper';
import Dialog from '../../../components/dialog.jsx';
import Dialog from '@components/dialog.jsx';
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
@@ -44,7 +44,7 @@ const NotificationPopup = ()=>{
return notifications.map((notification)=>(
<li key={notification.dismissKey} >
<em>{notification.title}</em><br />
<p dangerouslySetInnerHTML={{ __html: Markdown.render(notification.text) }}></p>
<p dangerouslySetInnerHTML={{ __html: hbfm.render(notification.text) }}></p>
</li>
));
};
@@ -62,4 +62,4 @@ const NotificationPopup = ()=>{
</Dialog>;
};
module.exports = NotificationPopup;
export default NotificationPopup;
@@ -1,3 +1,5 @@
@import './client/homebrew/navbar/navbar.less';
.popups {
position : fixed;
top : calc(@navbarHeight + @viewerToolsHeight);
+5 -5
View File
@@ -32,15 +32,15 @@ function safeHTML(htmlString) {
return;
}
// Check remaining elements for blacklisted attributes
for (const attribute of element.attributes){
[...element.attributes].forEach((attribute)=>{
if(blacklistAttrs.some((test)=>{return test(attribute);})) {
element.removeAttribute(attribute.localName);
break;
};
element.removeAttribute(attribute.name);
return;
};
});
});
return div.innerHTML;
};
module.exports.safeHTML = safeHTML;
export default safeHTML;
@@ -1,14 +1,15 @@
/* eslint-disable max-lines */
require('./toolBar.less');
const React = require('react');
const { useState, useEffect } = React;
const _ = require('lodash');
import './toolBar.less';
import React, { useState, useEffect } from 'react';
import _ from 'lodash';
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx';
import { Anchored, AnchoredBox, AnchoredTrigger } from '@components/Anchored.jsx';
const MAX_ZOOM = 300;
const MIN_ZOOM = 10;
const TOOLBAR_VISIBILITY = 'HB_renderer_toolbarVisibility';
const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPages, headerState, setHeaderState })=>{
const [pageNum, setPageNum] = useState(1);
@@ -21,7 +22,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
}, [visiblePages]);
useEffect(()=>{
const Visibility = localStorage.getItem('hb_toolbarVisibility');
const Visibility = localStorage.getItem(TOOLBAR_VISIBILITY);
if(Visibility) setToolsVisible(Visibility === 'true');
}, []);
@@ -98,46 +99,54 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
return (
<div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'>
<div className='toggleButton'>
<button title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{
setToolsVisible(!toolsVisible);
localStorage.setItem('hb_toolbarVisibility', !toolsVisible);
}}><i className='fas fa-glasses' /></button>
<button title={`${headerState ? 'Hide' : 'Show'} Header Navigation`} onClick={()=>{setHeaderState(!headerState);}}><i className='fas fa-rectangle-list' /></button>
<button data-tooltip-right={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`}
aria-label={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`}
onClick={()=>{ setToolsVisible(!toolsVisible); localStorage.setItem(TOOLBAR_VISIBILITY, !toolsVisible); }}>
<i aria-hidden='true' className='fas fa-glasses' />
</button>
<button data-tooltip-right={`${headerState ? 'Hide' : 'Show'} Header Navigation`}
aria-label={`${headerState ? 'Hide' : 'Show'} Header Navigation`}
onClick={()=>{setHeaderState(!headerState);}}>
<i aria-hidden='true' className='fas fa-rectangle-list' />
</button>
</div>
{/*v=====----------------------< Zoom Controls >---------------------=====v*/}
<div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}>
<button
id='fill-width'
className='tool'
title='Set zoom to fill preview with one page'
data-tooltip-bottom='Set zoom to fill preview with one page'
aria-label='Set zoom to fill preview with one page'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fill'))}
>
<i className='fac fit-width' />
<i aria-hidden='true' className='fac fit-width' />
</button>
<button
id='zoom-to-fit'
className='tool'
title='Set zoom to fit entire page in preview'
data-tooltip-bottom='Set zoom to fit entire page in preview'
aria-label='Set zoom to fit entire page in preview'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fit'))}
>
<i className='fac zoom-to-fit' />
<i aria-hidden='true' className='fac zoom-to-fit' />
</button>
<button
id='zoom-out'
className='tool'
onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)}
disabled={displayOptions.zoomLevel <= MIN_ZOOM}
title='Zoom Out'
data-tooltip-bottom='Zoom Out'
aria-label='Zoom Out'
>
<i className='fas fa-magnifying-glass-minus' />
<i aria-hidden='true' className='fas fa-magnifying-glass-minus' />
</button>
<input
id='zoom-slider'
className='range-input tool hover-tooltip'
type='range'
name='zoom'
title='Set Zoom'
list='zoomLevels'
aria-label='Zoom Amount'
min={MIN_ZOOM}
max={MAX_ZOOM}
step='1'
@@ -153,9 +162,10 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
className='tool'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)}
disabled={displayOptions.zoomLevel >= MAX_ZOOM}
title='Zoom In'
data-tooltip-bottom='Zoom In'
aria-label='Zoom In'
>
<i className='fas fa-magnifying-glass-plus' />
<i aria-hidden='true' className='fas fa-magnifying-glass-plus' />
</button>
</div>
@@ -165,44 +175,49 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
<button role='radio'
id='single-spread'
className='tool'
title='Single Page'
data-tooltip-bottom='Single Page'
aria-label='Single Page Spread'
onClick={()=>{handleOptionChange('spread', 'single');}}
aria-checked={displayOptions.spread === 'single'}
><i className='fac single-spread' /></button>
><i aria-hidden='true' className='fac single-spread' /></button>
<button role='radio'
id='facing-spread'
className='tool'
title='Facing Pages'
data-tooltip-bottom='Facing Pages'
aria-label='Facing Pages Spread'
onClick={()=>{handleOptionChange('spread', 'facing');}}
aria-checked={displayOptions.spread === 'facing'}
><i className='fac facing-spread' /></button>
><i aria-hidden='true' className='fac facing-spread' /></button>
<button role='radio'
id='flow-spread'
className='tool'
title='Flow Pages'
data-tooltip-bottom='Flow Pages'
aria-label='Flow Pages Spread'
onClick={()=>{handleOptionChange('spread', 'flow');}}
aria-checked={displayOptions.spread === 'flow'}
><i className='fac flow-spread' /></button>
><i aria-hidden='true' className='fac flow-spread' /></button>
</div>
<Anchored>
<AnchoredTrigger id='spread-settings' className='tool' title='Spread options'><i className='fas fa-gear' /></AnchoredTrigger>
<AnchoredBox title='Options'>
<AnchoredTrigger id='spread-settings' className='tool' aria-label='Spread options' data-tooltip-bottom='Spread options'>
<i aria-hidden='true' className='fas fa-gear' />
</AnchoredTrigger>
<AnchoredBox>
<h1>Options</h1>
<label title='Modify the horizontal space between pages.'>
<label data-tooltip-left='Modify the horizontal space between pages.'>
Column gap
<input type='range' min={0} max={200} defaultValue={displayOptions.columnGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('columnGap', evt.target.value)} />
</label>
<label title='Modify the vertical space between rows of pages.'>
<label data-tooltip-left='Modify the vertical space between rows of pages.'>
Row gap
<input type='range' min={0} max={200} defaultValue={displayOptions.rowGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('rowGap', evt.target.value)} />
</label>
<label title='Start 1st page on the right side, such as if you have cover page.'>
<label data-tooltip-left='Start 1st page on the right side, such as if you have cover page.'>
Start on right
<input type='checkbox' checked={displayOptions.startOnRight} onChange={()=>{handleOptionChange('startOnRight', !displayOptions.startOnRight);}}
title={displayOptions.spread !== 'facing' ? 'Switch to Facing to enable toggle.' : null} />
data-tooltip-right={displayOptions.spread !== 'facing' ? 'Switch to Facing to enable toggle.' : null} />
</label>
<label title='Toggle the page shadow on every page.'>
<label data-tooltip-left='Toggle the page shadow on every page.'>
Page shadows
<input type='checkbox' checked={displayOptions.pageShadows} onChange={()=>{handleOptionChange('pageShadows', !displayOptions.pageShadows);}} />
</label>
@@ -216,11 +231,12 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
id='previous-page'
className='previousPage tool'
type='button'
title='Previous Page(s)'
data-tooltip-bottom='Previous Page(s)'
aria-label='Previous Page'
onClick={()=>scrollToPage(_.min(visiblePages) - visiblePages.length)}
disabled={visiblePages.includes(1)}
>
<i className='fas fa-arrow-left'></i>
<i aria-hidden='true' className='fas fa-arrow-left'></i>
</button>
<div className='tool'>
@@ -229,7 +245,8 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
className='text-input'
type='text'
name='page'
title='Current page(s) in view'
data-tooltip-bottom='Current page(s) in view'
aria-label='Current page in view'
inputMode='numeric'
pattern='[0-9]'
value={pageNum}
@@ -239,22 +256,23 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
style={{ width: `${pageNum.length}ch` }}
/>
<span id='page-count' title='Total Page Count'>/ {totalPages}</span>
<span id='page-count' aria-label={`${totalPages} Total Pages`} data-tooltip-bottom='Total Page Count'><span aria-hidden='true'>/ {totalPages}</span></span>
</div>
<button
id='next-page'
className='tool'
type='button'
title='Next Page(s)'
data-tooltip-bottom='Next Page(s)'
aria-label='Next Page'
onClick={()=>scrollToPage(_.max(visiblePages) + 1)}
disabled={visiblePages.includes(totalPages)}
>
<i className='fas fa-arrow-right'></i>
<i aria-hidden='true' className='fas fa-arrow-right'></i>
</button>
</div>
</div>
);
};
module.exports = ToolBar;
export default ToolBar;
@@ -166,7 +166,7 @@
&.hidden {
flex-wrap : nowrap;
width : 92px;
width : 50%;
overflow : hidden;
background-color : unset;
opacity : 0.7;
+252 -453
View File
@@ -1,19 +1,26 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
require('./editor.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const dedent = require('dedent-tabs').default;
import Markdown from '../../../shared/naturalcrit/markdown.js';
import './editor.less';
import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
import dedent from 'dedent';
const CodeEditor = require('naturalcrit/codeEditor/codeEditor.jsx');
const SnippetBar = require('./snippetbar/snippetbar.jsx');
const MetadataEditor = require('./metadataEditor/metadataEditor.jsx');
import CodeEditor from '@components/codeEditor/codeEditor.jsx';
import SnippetBar from './snippetbar/snippetbar.jsx';
import MetadataEditor from './metadataEditor/metadataEditor.jsx';
const EDITOR_THEME_KEY = 'HOMEBREWERY-EDITOR-THEME';
const EDITOR_THEME_KEY = 'HB_editor_theme';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/;
import defaultCM5Theme from '@themes/codeMirror/default.js';
import darkbrewery from '@themes/codeMirror/darkbrewery.js';
import cm5Themes from 'codemirror-5-themes';
const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const EditorThemes = Object.entries(themes)
.filter(([name, value])=>Array.isArray(value) && !name.endsWith('Init') && !name.endsWith('Style'))
.map(([name])=>name);
//const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
//const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/;
const DEFAULT_STYLE_TEXT = dedent`
/*=======--- Example CSS styling ---=======*/
/* Any CSS here will apply to your document! */
@@ -30,513 +37,305 @@ const DEFAULT_SNIPPET_TEXT = dedent`
This snippet is accessible in the brew tab, and will be inherited if the brew is used as a theme.
`;
let isJumping = false;
let jumpSource = null;
const Editor = createClass({
displayName : 'Editor',
getDefaultProps : function() {
return {
brew : {
text : '',
style : ''
const Editor = forwardRef(
(
{
brew = {},
onBrewChange = ()=>{},
reportError = ()=>{},
onCursorPageChange = ()=>{},
onViewPageChange = ()=>{},
editorTheme = 'default',
renderer = 'legacy',
moveBrew,
moveSource,
liveScroll,
setMoveArrows,
updateBrew,
showEditButtons,
themeBundle,
userThemes,
currentEditorCursorPageNum = 1,
currentEditorViewPageNum = 1,
currentBrewRendererPageNum = 1,
},
ref,
)=>{
const [currentEditorTheme, setEditorTheme] = useState(editorTheme);
const [view, setView] = useState('text'); // 'text', 'style', 'meta', 'snippet'
const [snippetBarHeight, setSnippetBarHeight] = useState(26);
onTextChange : ()=>{},
onStyleChange : ()=>{},
onMetaChange : ()=>{},
onSnipChange : ()=>{},
reportError : ()=>{},
const editor = useRef(null);
const codeEditor = useRef(null);
const throttleBrewMove = useRef(null);
onCursorPageChange : ()=>{},
onViewPageChange : ()=>{},
const isText = ()=>isView('text');
const isStyle = ()=>isView('style');
const isMeta = ()=>isView('meta');
const isSnip = ()=>isView('snippet');
editorTheme : 'default',
renderer : 'legacy',
const isView = (name)=>view === name;
currentEditorCursorPageNum : 1,
currentEditorViewPageNum : 1,
currentBrewRendererPageNum : 1,
};
},
getInitialState : function() {
return {
editorTheme : this.props.editorTheme,
view : 'text', //'text', 'style', 'meta', 'snippet'
snippetbarHeight : 25
};
},
editor : React.createRef(null),
codeEditor : React.createRef(null),
isText : function() {return this.state.view == 'text';},
isStyle : function() {return this.state.view == 'style';},
isMeta : function() {return this.state.view == 'meta';},
isSnip : function() {return this.state.view == 'snippet';},
componentDidMount : function() {
this.highlightCustomMarkdown();
document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys);
document.addEventListener('keydown', this.handleControlKeys);
this.codeEditor.current.codeMirror.on('cursorActivity', (cm)=>{this.updateCurrentCursorPage(cm.getCursor());});
this.codeEditor.current.codeMirror.on('scroll', _.throttle(()=>{this.updateCurrentViewPage(this.codeEditor.current.getTopVisibleLine());}, 200));
useEffect(()=>{
const brewRenderer = document.getElementById('BrewRenderer');
brewRenderer.onload = ()=>brewRenderer.contentDocument?.addEventListener('keydown', handleControlKeys);
document.addEventListener('keydown', handleControlKeys);
const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
if(editorTheme) {
this.setState({
editorTheme : editorTheme
if(editorTheme && EditorThemes.includes(editorTheme)) setEditorTheme(editorTheme); else setEditorTheme('default');
const snippetBar = document.querySelector('.editor > .snippetBar');
if(!snippetBar) return;
const resizeObserver = new ResizeObserver((entries)=>{
const height = document.querySelector('.editor > .snippetBar').offsetHeight;
setSnippetBarHeight(height);
});
}
this.setState({ snippetbarHeight: document.querySelector('.editor > .snippetBar').offsetHeight });
},
resizeObserver.observe(snippetBar);
componentDidUpdate : function(prevProps, prevState, snapshot) {
return ()=>{
if(resizeObserver) resizeObserver.disconnect();
};
}, []);
this.highlightCustomMarkdown();
if(prevProps.moveBrew !== this.props.moveBrew)
this.brewJump();
useEffect(()=>{ if(moveBrew) brewJump(); }, [moveBrew]);
useEffect(()=>{ if(moveSource) sourceJump(); }, [moveSource]);
useEffect(()=>{ if(liveScroll) sourceJump(currentBrewRendererPageNum, false); }, [currentBrewRendererPageNum, liveScroll]);
useEffect(()=>{ if(liveScroll) brewJump(currentEditorViewPageNum, false); }, [currentEditorViewPageNum, liveScroll]);
useEffect(()=>{ if(liveScroll) brewJump(currentEditorCursorPageNum, false); }, [currentEditorCursorPageNum, liveScroll]);
if(prevProps.moveSource !== this.props.moveSource)
this.sourceJump();
const handleFormatCode = () => {
codeEditor.current?.formatCode();
};
if(this.props.liveScroll) {
if(prevProps.currentBrewRendererPageNum !== this.props.currentBrewRendererPageNum) {
this.sourceJump(this.props.currentBrewRendererPageNum, false);
} else if(prevProps.currentEditorViewPageNum !== this.props.currentEditorViewPageNum) {
this.brewJump(this.props.currentEditorViewPageNum, false);
} else if(prevProps.currentEditorCursorPageNum !== this.props.currentEditorCursorPageNum) {
this.brewJump(this.props.currentEditorCursorPageNum, false);
}
}
},
handleControlKeys : function(e){
const handleControlKeys = (e)=>{
if(!(e.ctrlKey && e.metaKey && e.shiftKey)) return;
const LEFTARROW_KEY = 37;
const RIGHTARROW_KEY = 39;
if(e.keyCode == RIGHTARROW_KEY) this.brewJump();
if(e.keyCode == LEFTARROW_KEY) this.sourceJump();
if(e.keyCode == RIGHTARROW_KEY) brewJump();
if(e.keyCode == LEFTARROW_KEY) sourceJump();
if(e.keyCode == LEFTARROW_KEY || e.keyCode == RIGHTARROW_KEY) {
e.stopPropagation();
e.preventDefault();
}
},
updateCurrentCursorPage : function(cursor) {
const lines = this.props.brew.text.split('\n').slice(1, cursor.line + 1);
const pageRegex = this.props.brew.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
const currentPage = lines.reduce((count, line)=>count + (pageRegex.test(line) ? 1 : 0), 1);
this.props.onCursorPageChange(currentPage);
},
updateCurrentViewPage : function(topScrollLine) {
const lines = this.props.brew.text.split('\n').slice(1, topScrollLine + 1);
const pageRegex = this.props.brew.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
const currentPage = lines.reduce((count, line)=>count + (pageRegex.test(line) ? 1 : 0), 1);
this.props.onViewPageChange(currentPage);
},
handleInject : function(injectText){
this.codeEditor.current?.injectText(injectText, false);
},
handleViewChange : function(newView){
this.props.setMoveArrows(newView === 'text');
this.setState({
view : newView
}, ()=>{
this.codeEditor.current?.codeMirror.focus();
});
},
highlightCustomMarkdown : function(){
if(!this.codeEditor.current) return;
if((this.state.view === 'text') ||(this.state.view === 'snippet')) {
const codeMirror = this.codeEditor.current.codeMirror;
codeMirror.operation(()=>{ // Batch CodeMirror styling
const foldLines = [];
//reset custom text styles
const customHighlights = codeMirror.getAllMarks().filter((mark)=>{
// Record details of folded sections
if(mark.__isFold) {
const fold = mark.find();
foldLines.push({ from: fold.from?.line, to: fold.to?.line });
}
return !mark.__isFold;
}); //Don't undo code folding
for (let i=customHighlights.length - 1;i>=0;i--) customHighlights[i].clear();
let userSnippetCount = 1; // start snippet count from snippet 1
let editorPageCount = 1; // start page count from page 1
const whichSource = this.state.view === 'text' ? this.props.brew.text : this.props.brew.snippets;
_.forEach(whichSource?.split('\n'), (line, lineNumber)=>{
const tabHighlight = this.state.view === 'text' ? 'pageLine' : 'snippetLine';
const textOrSnip = this.state.view === 'text';
//reset custom line styles
codeMirror.removeLineClass(lineNumber, 'background', 'pageLine');
codeMirror.removeLineClass(lineNumber, 'background', 'snippetLine');
codeMirror.removeLineClass(lineNumber, 'text');
codeMirror.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash');
// Don't process lines inside folded text
// If the current lineNumber is inside any folded marks, skip line styling
if(foldLines.some((fold)=>lineNumber >= fold.from && lineNumber <= fold.to))
return;
// Styling for \page breaks
if((this.props.renderer == 'legacy' && line.includes('\\page')) ||
(this.props.renderer == 'V3' && line.match(textOrSnip ? PAGEBREAK_REGEX_V3 : SNIPPETBREAK_REGEX_V3))) {
if((lineNumber > 0) && (textOrSnip)) // Since \page is optional on first line of document,
editorPageCount += 1; // don't use it to increment page count; stay at 1
else if(this.state.view !== 'text') userSnippetCount += 1;
// add back the original class 'background' but also add the new class '.pageline'
codeMirror.addLineClass(lineNumber, 'background', tabHighlight);
const pageCountElement = Object.assign(document.createElement('span'), {
className : 'editor-page-count',
textContent : textOrSnip ? editorPageCount : userSnippetCount
});
codeMirror.setBookmark({ line: lineNumber, ch: line.length }, pageCountElement);
};
const updateCurrentCursorPage = (pageNumber)=>{
onCursorPageChange(pageNumber);
};
// New Codemirror styling for V3 renderer
if(this.props.renderer === 'V3') {
if(line.match(/^\\column(?:break)?$/)){
codeMirror.addLineClass(lineNumber, 'text', 'columnSplit');
}
const updateCurrentViewPage = (pageNumber)=>{
onViewPageChange(pageNumber);
};
// definition lists
if(line.includes('::')){
if(/^:*$/.test(line) == true){ return; };
const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/ymd; // the `d` flag, for match indices, throws an ESLint error.
let match;
while ((match = regex.exec(line)) != null){
codeMirror.markText({ line: lineNumber, ch: match.indices[0][0] }, { line: lineNumber, ch: match.indices[0][1] }, { className: 'dl-highlight' });
codeMirror.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' });
codeMirror.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
const ddIndex = match.indices[2][0];
const colons = /::/g;
const colonMatches = colons.exec(match[2]);
if(colonMatches !== null){
codeMirror.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight' });
}
}
}
const handleInject = (injectText)=>{
codeEditor.current?.injectText(injectText);
};
// Subscript & Superscript
if(line.includes('^')) {
let startIndex = line.indexOf('^');
const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy;
const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy;
const handleViewChange = (newView)=>{
setMoveArrows(newView === 'text');
setView(newView);
};
useEffect(()=>{
codeEditor.current?.focus();
}, [view]);
while (startIndex >= 0) {
superRegex.lastIndex = subRegex.lastIndex = startIndex;
let isSuper = false;
const match = subRegex.exec(line) || superRegex.exec(line);
if(match) {
isSuper = !subRegex.lastIndex;
codeMirror.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' });
}
startIndex = line.indexOf('^', Math.max(startIndex + 1, subRegex.lastIndex, superRegex.lastIndex));
}
}
const brewJump = (targetPage = currentEditorCursorPageNum, smooth = true)=>{
if(!window || !isText() || isJumping || jumpSource === 'source') return;
// Highlight injectors {style}
if(line.includes('{') && line.includes('}')){
const regex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gm;
let match;
while ((match = regex.exec(line)) != null) {
codeMirror.markText({ line: lineNumber, ch: line.indexOf(match[1]) }, { line: lineNumber, ch: line.indexOf(match[1]) + match[1].length }, { className: 'injection' });
}
}
// Highlight inline spans {{content}}
if(line.includes('{{') && line.includes('}}')){
const regex = /{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *|}}/g;
let match;
let blockCount = 0;
while ((match = regex.exec(line)) != null) {
if(match[0].startsWith('{')) {
blockCount += 1;
} else {
blockCount -= 1;
}
if(blockCount < 0) {
blockCount = 0;
continue;
}
codeMirror.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: 'inline-block' });
}
} else if(line.trimLeft().startsWith('{{') || line.trimLeft().startsWith('}}')){
// Highlight block divs {{\n Content \n}}
let endCh = line.length+1;
const brewRenderer =
window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
const match = line.match(/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/);
if(match)
endCh = match.index+match[0].length;
codeMirror.markText({ line: lineNumber, ch: 0 }, { line: lineNumber, ch: endCh }, { className: 'block' });
}
// Emojis
if(line.match(/:[^\s:]+:/g)) {
let startIndex = line.indexOf(':');
const emojiRegex = /:[^\s:]+:/gy;
while (startIndex >= 0) {
emojiRegex.lastIndex = startIndex;
const match = emojiRegex.exec(line);
if(match) {
let tokens = Markdown.marked.lexer(match[0]);
tokens = tokens[0].tokens.filter((t)=>t.type == 'emoji');
if(!tokens.length)
return;
const startPos = { line: lineNumber, ch: match.index };
const endPos = { line: lineNumber, ch: match.index + match[0].length };
// Iterate over conflicting marks and clear them
const marks = codeMirror.findMarks(startPos, endPos);
marks.forEach(function(marker) {
if(!marker.__isFold) marker.clear();
});
codeMirror.markText(startPos, endPos, { className: 'emoji' });
}
startIndex = line.indexOf(':', Math.max(startIndex + 1, emojiRegex.lastIndex));
}
}
}
});
});
}
},
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
if(!window || !this.isText() || isJumping)
return;
// Get current brewRenderer scroll position and calculate target position
const brewRenderer = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
const currentPos = brewRenderer.scrollTop;
const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top;
const checkIfScrollComplete = ()=>{
const targetPos = window.frames['BrewRenderer'].contentDocument
.getElementById(`p${targetPage}`)
.getBoundingClientRect().top;
let scrollingTimeout;
const checkIfScrollComplete = ()=>{// Prevent interrupting a scroll in progress if user clicks multiple times
clearTimeout(scrollingTimeout);// Reset the timer every time a scroll event occurs
scrollingTimeout = setTimeout(()=>{
isJumping = false;
jumpSource = null;
brewRenderer.removeEventListener('scroll', checkIfScrollComplete);
}, 150);// If 150 ms pass without a brewRenderer scroll event, assume scrolling is done
};
isJumping = true;
jumpSource = 'brew';
checkIfScrollComplete();
brewRenderer.addEventListener('scroll', checkIfScrollComplete);
if(smooth) {
const bouncePos = targetPos >= 0 ? -30 : 30; //Do a little bounce before scrolling
const bounceDelay = 100;
const scrollDelay = 500;
const now = Date.now();
if(now - throttleBrewMove.current >= 500) {
throttleBrewMove.current = now;
if(!this.throttleBrewMove) {
this.throttleBrewMove = _.throttle((currentPos, bouncePos, targetPos)=>{
brewRenderer.scrollTo({ top: currentPos + bouncePos, behavior: 'smooth' });
setTimeout(()=>{
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' });
}, bounceDelay);
}, scrollDelay, { leading: true, trailing: false });
};
this.throttleBrewMove(currentPos, bouncePos, targetPos);
}, 100);
}
} else {
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'instant', block: 'start' });
brewRenderer.scrollTo({ top : currentPos + targetPos, behavior : 'instant', block : 'start',
});
}
},
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
if(!this.isText() || isJumping)
return;
const textSplit = this.props.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
const textString = this.props.brew.text.split(textSplit).slice(0, targetPage-1).join(textSplit);
const targetLine = textString.match('\n') ? textString.split('\n').length - 1 : -1;
let currentY = this.codeEditor.current.codeMirror.getScrollInfo().top;
let targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
const checkIfScrollComplete = ()=>{
let scrollingTimeout;
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
scrollingTimeout = setTimeout(()=>{
isJumping = false;
this.codeEditor.current.codeMirror.off('scroll', checkIfScrollComplete);
}, 150); // If 150 ms pass without a scroll event, assume scrolling is done
};
isJumping = true;
checkIfScrollComplete();
this.codeEditor.current.codeMirror.on('scroll', checkIfScrollComplete);
const sourceJump = (targetPage = currentBrewRendererPageNum, smooth = true)=>{
if(!isText() || isJumping || jumpSource === 'brew') return;
if(smooth) {
//Scroll 1/10 of the way every 10ms until 1px off.
const incrementalScroll = setInterval(()=>{
currentY += (targetY - currentY) / 10;
this.codeEditor.current.codeMirror.scrollTo(null, currentY);
if(!codeEditor.current) return;
jumpSource = 'source';
// Update target: target height is not accurate until within +-10 lines of the visible window
if(Math.abs(targetY - currentY > 100))
targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
codeEditor.current.scrollToPage(targetPage);
setTimeout(()=>{
jumpSource = null;
}, 200);
};
// End when close enough
if(Math.abs(targetY - currentY) < 1) {
this.codeEditor.current.codeMirror.scrollTo(null, targetY); // Scroll any remaining difference
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
this.codeEditor.current.codeMirror.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
clearInterval(incrementalScroll);
const updateEditorTheme = (newTheme)=>{
window.localStorage.setItem(EDITOR_THEME_KEY, newTheme);
setEditorTheme(newTheme);
};
const renderEditor = ()=>{
if(isText()) {
return (
<>
<CodeEditor
key='codeEditor'
ref={codeEditor}
language='gfm'
tab='brewText'
view={view}
value={brew.text}
onChange={onBrewChange('text')}
onCursorChange={(page)=>updateCurrentCursorPage(page)}
onViewChange={(page)=>updateCurrentViewPage(page)}
editorTheme={currentEditorTheme}
renderer={brew.renderer}
style={{ height: `calc(100% - ${snippetBarHeight}px)` }}
/>
</>
);
}
}, 10);
} else {
this.codeEditor.current.codeMirror.scrollTo(null, targetY); // Scroll any remaining difference
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
this.codeEditor.current.codeMirror.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
if(isStyle()) {
return (
<>
<CodeEditor
key='codeEditor'
ref={codeEditor}
language='css'
tab='brewStyles'
view={view}
value={brew.style ?? DEFAULT_STYLE_TEXT}
onChange={onBrewChange('style')}
editorTheme={currentEditorTheme}
renderer={brew.renderer}
style={{ height: `calc(100% - ${snippetBarHeight}px)` }}
/>
</>
);
}
},
if(isSnip()) {
if(!brew.snippets) {
brew.snippets = DEFAULT_SNIPPET_TEXT;
}
return (
<>
<CodeEditor
key='codeEditor'
ref={codeEditor}
language='gfm'
tab='brewSnippets'
view={view}
value={brew.snippets}
onChange={onBrewChange('snippets')}
enableFolding={true}
editorTheme={currentEditorTheme}
renderer={brew.renderer}
style={{ height: `calc(100% - 25px)` }}
/>
</>
);
}
if(isMeta()) {
return (
<>
<CodeEditor key='codeEditor' view={view} style={{ display: 'none' }} />
<MetadataEditor
metadata={brew}
themeBundle={themeBundle}
onChange={onBrewChange('metadata')}
reportError={reportError}
userThemes={userThemes}
/>
</>
);
}
};
const redo = ()=>codeEditor.current?.redo();
const historySize = ()=>codeEditor.current?.historySize();
const undo = ()=>codeEditor.current?.undo();
const foldCode = ()=>codeEditor.current?.foldAll();
const unfoldCode = ()=>codeEditor.current?.unfoldAll();
//Called when there are changes to the editor's dimensions
update : function(){
this.codeEditor.current?.updateSize();
const snipHeight = document.querySelector('.editor > .snippetBar').offsetHeight;
if(snipHeight !== this.state.snippetbarHeight)
this.setState({ snippetbarHeight: snipHeight });
},
const update = ()=>{};
updateEditorTheme : function(newTheme){
window.localStorage.setItem(EDITOR_THEME_KEY, newTheme);
this.setState({
editorTheme : newTheme
});
},
useImperativeHandle(ref, ()=>({
update,
undo,
redo,
foldCode,
unfoldCode,
historySize,
}));
//Called by CodeEditor after document switch, so Snippetbar can refresh UndoHistory
rerenderParent : function (){
this.forceUpdate();
},
renderEditor : function(){
if(this.isText()){
return <>
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='gfm'
view={this.state.view}
value={this.props.brew.text}
onChange={this.props.onTextChange}
editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent}
style={{ height: `calc(100% - ${this.state.snippetbarHeight}px)` }} />
</>;
}
if(this.isStyle()){
return <>
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='css'
view={this.state.view}
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT}
onChange={this.props.onStyleChange}
enableFolding={true}
editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent}
style={{ height: `calc(100% - ${this.state.snippetbarHeight}px)` }} />
</>;
}
if(this.isMeta()){
return <>
<CodeEditor key='codeEditor'
view={this.state.view}
style={{ display: 'none' }}
rerenderParent={this.rerenderParent} />
<MetadataEditor
metadata={this.props.brew}
themeBundle={this.props.themeBundle}
onChange={this.props.onMetaChange}
reportError={this.props.reportError}
userThemes={this.props.userThemes}/>
</>;
}
if(this.isSnip()){
if(!this.props.brew.snippets) { this.props.brew.snippets = DEFAULT_SNIPPET_TEXT; }
return <>
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='gfm'
view={this.state.view}
value={this.props.brew.snippets}
onChange={this.props.onSnipChange}
enableFolding={true}
editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent}
style={{ height: `calc(100% - ${this.state.snippetbarHeight}px)` }} />
</>;
}
},
redo : function(){
return this.codeEditor.current?.redo();
},
historySize : function(){
return this.codeEditor.current?.historySize();
},
undo : function(){
return this.codeEditor.current?.undo();
},
foldCode : function(){
return this.codeEditor.current?.foldAllCode();
},
unfoldCode : function(){
return this.codeEditor.current?.unfoldAllCode();
},
render : function(){
return (
<div className='editor' ref={this.editor}>
<div className='editor' ref={editor}>
<SnippetBar
brew={this.props.brew}
view={this.state.view}
onViewChange={this.handleViewChange}
onInject={this.handleInject}
showEditButtons={this.props.showEditButtons}
renderer={this.props.renderer}
theme={this.props.brew.theme}
undo={this.undo}
redo={this.redo}
foldCode={this.foldCode}
unfoldCode={this.unfoldCode}
historySize={this.historySize()}
currentEditorTheme={this.state.editorTheme}
updateEditorTheme={this.updateEditorTheme}
themeBundle={this.props.themeBundle}
cursorPos={this.codeEditor.current?.getCursorPosition() || {}}
updateBrew={this.props.updateBrew}
brew={brew}
view={view}
onViewChange={handleViewChange}
onInject={handleInject}
showEditButtons={showEditButtons}
renderer={renderer}
theme={brew.theme}
undo={undo}
redo={redo}
foldCode={foldCode}
unfoldCode={unfoldCode}
formatCode={isStyle() ? handleFormatCode : null}
historySize={historySize()}
currentEditorTheme={currentEditorTheme}
updateEditorTheme={updateEditorTheme}
themeBundle={themeBundle}
cursorPos={codeEditor.current?.getCursorPosition() || {}}
updateBrew={updateBrew}
/>
{this.renderEditor()}
{renderEditor()}
</div>
);
}
});
);
module.exports = Editor;
export default Editor;
+4 -80
View File
@@ -1,87 +1,11 @@
@import 'themes/codeMirror/customEditorStyles.less';
.editor {
@import '@sharedStyles/core.less';
:where(.editor) {
position : relative;
width : 100%;
height : 100%;
container : editor / inline-size;
.codeEditor {
height : calc(100% - 25px);
.CodeMirror { height : 100%; }
.pageLine, .snippetLine {
background : #33333328;
border-top : #333399 solid 1px;
}
.editor-page-count {
float : right;
color : grey;
}
.editor-snippet-count {
float : right;
color : grey;
}
.columnSplit {
font-style : italic;
color : grey;
background-color : fade(#229999, 15%);
border-bottom : #229999 solid 1px;
}
.define {
&:not(.term):not(.definition) {
font-weight : bold;
color : #949494;
background : #E5E5E5;
border-radius : 3px;
}
&.term { color : rgb(96, 117, 143); }
&.definition { color : rgb(97, 57, 178); }
}
.block:not(.cm-comment) {
font-weight : bold;
color : purple;
//font-style: italic;
}
.inline-block:not(.cm-comment) {
font-weight : bold;
color : red;
//font-style: italic;
}
.injection:not(.cm-comment) {
font-weight : bold;
color : green;
}
.emoji:not(.cm-comment) {
padding-bottom : 1px;
margin-left : 2px;
font-weight : bold;
color : #360034;
outline : solid 2px #FF96FC;
outline-offset : -2px;
background : #FFC8FF;
border-radius : 6px;
}
.superscript:not(.cm-comment) {
font-size : 0.9em;
font-weight : bold;
vertical-align : super;
color : goldenrod;
}
.subscript:not(.cm-comment) {
font-size : 0.9em;
font-weight : bold;
vertical-align : sub;
color : rgb(123, 123, 15);
}
.dl-highlight {
&.dl-colon-highlight {
font-weight : bold;
color : #949494;
background : #E5E5E5;
border-radius : 3px;
}
&.dt-highlight { color : rgb(96, 117, 143); }
&.dd-highlight { color : rgb(97, 57, 178); }
}
}
background : white;
.brewJump {
position : absolute;
@@ -1,19 +1,17 @@
/* eslint-disable max-lines */
require('./metadataEditor.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
import './metadataEditor.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import request from '../../utils/request-middleware.js';
const Combobox = require('client/components/combobox.jsx');
const TagInput = require('../tagInput/tagInput.jsx');
import Combobox from '@components/combobox.jsx';
import TagInput from '../tagInput/tagInput.jsx';
const Themes = require('themes/themes.json');
const validations = require('./validations.js');
import Themes from '@themes/themes.json';
import validations from './validations.js';
const SYSTEMS = ['5e', '4e', '3.5e', 'Pathfinder'];
const homebreweryThumbnail = require('../../thumbnail.png');
import homebreweryThumbnail from '../../thumbnail.png';
const callIfExists = (val, fn, ...args)=>{
if(val[fn]) {
@@ -21,7 +19,7 @@ const callIfExists = (val, fn, ...args)=>{
}
};
const MetadataEditor = createClass({
const MetadataEditor = createReactClass({
displayName : 'MetadataEditor',
getDefaultProps : function() {
return {
@@ -34,7 +32,6 @@ const MetadataEditor = createClass({
tags : [],
published : false,
authors : [],
systems : [],
renderer : 'legacy',
theme : '5ePHB',
lang : 'en'
@@ -47,6 +44,7 @@ const MetadataEditor = createClass({
getInitialState : function(){
return {
isOwner : global.account?.username && global.account?.username === this.props.metadata?.authors[0],
showThumbnail : true
};
},
@@ -92,15 +90,6 @@ const MetadataEditor = createClass({
}
},
handleSystem : function(system, e){
if(e.target.checked){
this.props.metadata.systems.push(system);
} else {
this.props.metadata.systems = _.without(this.props.metadata.systems, system);
}
this.props.onChange(this.props.metadata);
},
handleRenderer : function(renderer, e){
if(e.target.checked){
this.props.metadata.renderer = renderer;
@@ -156,26 +145,23 @@ const MetadataEditor = createClass({
});
},
renderSystems : function(){
return _.map(SYSTEMS, (val)=>{
return <label key={val}>
<input
type='checkbox'
checked={_.includes(this.props.metadata.systems, val)}
onChange={(e)=>this.handleSystem(val, e)} />
{val}
</label>;
handleDeleteAuthor : function(author){
if(!confirm('Are you sure you want to remove this author? They will lose all edit access to this brew, and it will dissapear from their userpage.')) return;
if(!this.props.metadata.authors.includes(author)) return;
this.props.onChange({
...this.props.metadata,
authors : this.props.metadata.authors.filter((a)=>a !== author)
});
},
renderPublish : function(){
if(this.props.metadata.published){
return <button className='unpublish' onClick={()=>this.handlePublish(false)}>
<i className='fas fa-ban' /> unpublish
<i className='fas fa-ban' aria-hidden='true' /> unpublish
</button>;
} else {
return <button className='publish' onClick={()=>this.handlePublish(true)}>
<i className='fas fa-globe' /> publish
<i className='fas fa-globe' aria-hidden='true' /> publish
</button>;
}
},
@@ -194,21 +180,57 @@ const MetadataEditor = createClass({
},
renderAuthors : function(){
let text = 'None.';
if(this.props.metadata.authors && this.props.metadata.authors.length){
text = this.props.metadata.authors.join(', ');
}
return <div className='field authors'>
const authors = this.props.metadata.authors;
if(!this.state.isOwner || authors.length < 2) return (
<div className='field authors'>
<label>authors</label>
<div className='value'>
{text}
{authors.length > 0 && (
<a href={`/user/${authors[0]}`} className='author-link' target="_blank" title={`Owner - Click to open ${authors[0]}'s profile in a new tab`}>
{authors[0]}{authors.length > 1 && ', '}
</a>
)}
{authors.length > 1 && authors.slice(1).map((author, i)=>(
<a href={`/user/${author}`} className='author-link' title={`Author - Click to open ${author}'s profile in a new tab`}>
{author}{i+2 < authors.length && ', '}
</a>
))}
</div>
</div>;
</div>
);
return (
<div className='field authors'>
<label>Authors</label>
<ul className='list'>
{authors.length > 0 && (
<li className='tag owner' title='Owner'>
<a href={`/user/${authors[0]}`} className='author-link' title={`Owner - Click to open ${authors[0]}'s profile in a new tab`}>
{authors[0]}
</a>
</li>
)}
{authors.length > 1 && authors.slice(1).map((author, i)=>(
<li className='tag author' key={i + 1} title='Author'>
<a href={`/user/${author}`} className='author-link' title={`Author - Click to open ${authors[0]}'s profile in a new tab`}>
{author}
</a>
<button
onClick={()=>this.handleDeleteAuthor(author)}
className='delete'
title={`Remove ${author} as an author`}
>
<i className='fa fa-times fa-fw' />
</button>
</li>
))}
</ul>
</div>
);
},
renderThemeDropdown : function(){
if(!global.enable_themes) return;
const mergedThemes = _.merge(Themes, this.props.userThemes);
const listThemes = (renderer)=>{
@@ -240,7 +262,7 @@ const MetadataEditor = createClass({
</div>;
} else {
dropdown =
<div className='value'>
<div className='value' data-tooltip-top='Select from the list below (built-in themes and brews you have tagged "meta:theme"), or paste in the Share URL or Share ID of any brew.'>
<Combobox trigger='click'
className='themes-dropdown'
default={currentThemeDisplay}
@@ -258,7 +280,6 @@ const MetadataEditor = createClass({
filterOn : ['value', 'title']
}}
/>
<small>Select from the list below (built-in themes and brews you have tagged "meta:theme"), or paste in the Share URL or Share ID of any brew.</small>
</div>;
}
@@ -283,7 +304,7 @@ const MetadataEditor = createClass({
return <div className='field language'>
<label>language</label>
<div className='value'>
<div className='value' data-tooltip-right='Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.'>
<Combobox trigger='click'
className='language-dropdown'
default={this.props.metadata.lang || ''}
@@ -300,16 +321,13 @@ const MetadataEditor = createClass({
filterOn : ['value', 'detail', 'title']
}}
/>
<small>Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.</small>
</div>
</div>;
},
renderRenderOptions : function(){
if(!global.enable_v3) return;
return <div className='field systems'>
return <div className='field renderers'>
<label>Renderer</label>
<div className='value'>
<label key='legacy'>
@@ -341,26 +359,28 @@ const MetadataEditor = createClass({
<h1>Properties Editor</h1>
<div className='field title'>
<label>title</label>
<input type='text' className='value'
<label for='title_field'>title</label>
<input type='text' id='title_field' className='value'
defaultValue={this.props.metadata.title}
onChange={(e)=>this.handleFieldChange('title', e)} />
</div>
<div className='field-group'>
<div className='field-column'>
<div className='field description'>
<label>description</label>
<textarea defaultValue={this.props.metadata.description} className='value'
<label for='description_field'>description</label>
<textarea id='description_field' defaultValue={this.props.metadata.description} className='value'
onChange={(e)=>this.handleFieldChange('description', e)} />
</div>
<div className='field thumbnail'>
<label>thumbnail</label>
<label for='thumbnail_field'>thumbnail</label>
<input type='text'
id='thumbnail_field'
defaultValue={this.props.metadata.thumbnail}
placeholder='https://my.thumbnail.url'
className='value'
onChange={(e)=>this.handleFieldChange('thumbnail', e)} />
<button className='display' onClick={this.toggleThumbnailDisplay}>
<button className='display' onClick={this.toggleThumbnailDisplay}
aria-label={`${this.state.showThumbnail ? 'hide thumbnail' : 'show thumbnail'}`}>
<i className={`fas fa-caret-${this.state.showThumbnail ? 'right' : 'left'}`} />
</button>
</div>
@@ -368,18 +388,20 @@ const MetadataEditor = createClass({
{this.renderThumbnail()}
</div>
<TagInput label='tags' valuePatterns={[/^(?:(?:group|meta|system|type):)?[A-Za-z0-9][A-Za-z0-9 \/.\-]{0,40}$/]}
<div className='field tags'>
<label>Tags</label>
<div className='value' >
<TagInput
label='tags'
valuePatterns={/^\s*(?:(?:group|meta|system|type)\s*:\s*)?[A-Za-z0-9][A-Za-z0-9 \/\\.&_\-]{0,40}\s*$/}
placeholder='add tag' unique={true}
values={this.props.metadata.tags}
onChange={(e)=>this.handleFieldChange('tags', e)}
tooltip='You may start tags with "type", "system", "group" or "meta" followed by a colon ":", these will be colored in your userpage.'
/>
</div>
</div>
<div className='field systems'>
<label>systems</label>
<div className='value'>
{this.renderSystems()}
</div>
</div>
{this.renderLanguageDropdown()}
@@ -391,13 +413,22 @@ const MetadataEditor = createClass({
{this.renderAuthors()}
<TagInput label='invited authors' valuePatterns={[/.+/]}
<div className='field invitedAuthors'>
<label>Invited authors</label>
<div className='value'>
<TagInput
label='invited authors'
valuePatterns={/.+/}
validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
placeholder='invite author' unique={true}
tooltip={`Invited author usernames are case sensitive.
After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.`}
values={this.props.metadata.invitedAuthors}
notes={['Invited author usernames are case sensitive.', 'After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.']}
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}
/>
</div>
</div>
<h2>Privacy</h2>
@@ -415,4 +446,4 @@ const MetadataEditor = createClass({
}
});
module.exports = MetadataEditor;
export default MetadataEditor;
@@ -1,4 +1,4 @@
@import 'naturalcrit/styles/colors.less';
@import '@sharedStyles/core.less';
.userThemeName {
padding-right : 10px;
@@ -44,8 +44,6 @@
gap : 10px;
}
.field {
position : relative;
display : flex;
@@ -62,6 +60,7 @@
& > .value {
flex : 1 1 auto;
width : 50px;
&[data-tooltip-right] { max-width : 380px; }
&:invalid { background : #FFB9B9; }
small {
display : block;
@@ -74,6 +73,16 @@
border : 1px solid gray;
&:focus { outline : 1px solid #444444; }
}
&.description {
flex : 1;
textarea.value {
height : auto;
font-family : 'Open Sans', sans-serif;
resize : none;
}
}
&.thumbnail, &.themes {
label { line-height : 2.0em; }
.value {
@@ -90,6 +99,15 @@
}
}
&.tags .tagInput-dropdown {
z-index : 400;
max-width : 200px;
}
&.language .value {
z-index : 300;
max-width : 150px;
}
&.themes {
.value {
overflow : visible;
@@ -101,22 +119,13 @@
}
}
&.description {
flex : 1;
textarea.value {
height : auto;
font-family : 'Open Sans', sans-serif;
resize : none;
}
}
&.invitedAuthors .value {
z-index : 100;
&.language .language-dropdown {
z-index : 200;
max-width : 150px;
.tagInput-dropdown { max-width : 200px; }
}
}
.thumbnail-preview {
position : relative;
flex : 1 1;
@@ -129,7 +138,7 @@
background-color : #AAAAAA;
}
.systems.field .value {
.renderers.field .value {
label {
display : inline-flex;
align-items : center;
@@ -164,12 +173,56 @@
.colorButton(@red);
}
}
.authors.field .value { line-height : 1.5em; }
.authors.field {
.tag {
font-weight:300;
transition:background-color 0.2s;
&.owner {
position: relative;
background-color:@silverLight;
min-width:25px;
display:grid;
place-items:center;
font-weight: 900;
&::after {
content: "\f521";
font-family: "Font Awesome 6 Free";
color:gold;
position: absolute;
top: 0;
left: 0;
width:15px;
height:15px;
rotate:-25deg;
translate:-30% -50%;
transform: scaleY(0.7);
}
}
&:has(button) a {
padding-right:5px;
}
&:has(button:hover) {
background:#d97d7d;
}
button {
color:@red;
}
}
a {
color:black;
text-underline-offset:0.2em;
}
}
.themes.field {
& .dropdown-container {
position : relative;
z-index : 100;
z-index : 200;
background-color : white;
}
& .dropdown-options { overflow-y : visible; }
@@ -266,13 +319,17 @@
}
.tag {
padding : 0.3em;
padding : 0.35em;
margin : 2px;
font-size : 0.9em;
font-size : 0.95em;
background-color : #DDDDDD;
border-radius : 0.5em;
.icon { #groupedIcon; }
button {
cursor : pointer;
}
}
.input-group {
@@ -1,4 +1,4 @@
module.exports = {
export default {
title : [
(value)=>{
return value?.length > 100 ? 'Max title length of 100 characters' : null;
@@ -18,7 +18,7 @@ module.exports = {
try {
Boolean(new URL(value));
return null;
} catch (e) {
} catch {
return 'Must be a valid URL';
}
}
@@ -1,29 +1,55 @@
/*eslint max-lines: ["warn", {"max": 350, "skipBlankLines": true, "skipComments": true}]*/
require('./snippetbar.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
import './snippetbar.less';
import React from 'react';
import createReactClass from 'create-react-class';
import { Dropdown } from '@components/dropdown/dropdown.jsx';
import _ from 'lodash';
import cx from 'classnames';
import { loadHistory } from '../../utils/versionHistory.js';
import { brewSnippetsToJSON } from '../../../../shared/helpers.js';
import { brewSnippetsToJSON } from '@shared/helpers.js';
//Import all themes
const ThemeSnippets = {};
ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js');
ThemeSnippets['V3_5ePHB'] = require('themes/V3/5ePHB/snippets.js');
ThemeSnippets['V3_5eDMG'] = require('themes/V3/5eDMG/snippets.js');
ThemeSnippets['V3_Journal'] = require('themes/V3/Journal/snippets.js');
ThemeSnippets['V3_Blank'] = require('themes/V3/Blank/snippets.js');
import Legacy5ePHB from '@themes/Legacy/5ePHB/snippets.js';
import V3_5ePHB from '@themes/V3/5ePHB/snippets.js';
import V3_5eDMG from '@themes/V3/5eDMG/snippets.js';
import V3_Journal from '@themes/V3/Journal/snippets.js';
import V3_Blank from '@themes/V3/Blank/snippets.js';
const EditorThemes = require('build/homebrew/codeMirror/editorThemes.json');
const ThemeSnippets = {
Legacy_5ePHB : Legacy5ePHB,
V3_5ePHB : V3_5ePHB,
V3_5eDMG : V3_5eDMG,
V3_Journal : V3_Journal,
V3_Blank : V3_Blank,
};
import defaultCM5Theme from '@themes/codeMirror/default.js';
import darkbrewery from '@themes/codeMirror/darkbrewery.js';
import cm5Themes from 'codemirror-5-themes';
const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const themeNames = Object.entries(themes)
.filter(([name, value])=>Array.isArray(value) &&
!name.endsWith('Init') &&
!name.endsWith('Style')
)
.map(([name])=>name);
const EditorThemes = [
'default',
...themeNames
.filter((name)=>name !== 'default')
.sort((a, b)=>a.localeCompare(b))
];
const execute = function(val, props){
if(_.isFunction(val)) return val(props);
return val;
};
const Snippetbar = createClass({
const Snippetbar = createReactClass({
displayName : 'SnippetBar',
getDefaultProps : function() {
return {
@@ -39,6 +65,7 @@ const Snippetbar = createClass({
historySize : ()=>{},
foldCode : ()=>{},
unfoldCode : ()=>{},
formatCode : ()=>{},
updateEditorTheme : ()=>{},
cursorPos : {},
themeBundle : [],
@@ -144,7 +171,7 @@ const Snippetbar = createClass({
this.props.updateEditorTheme(e.target.value);
this.setState({
showThemeSelector : false,
themeSelector : false,
});
},
@@ -162,7 +189,7 @@ const Snippetbar = createClass({
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
if(snippets.length === 0) return null;
return <div className='snippets'>
return <ul className='snippets' role='menubar' aria-label='Snippets Menubar'>
{_.map(snippets, (snippetGroup)=>{
return <SnippetGroup
brew={this.props.brew}
@@ -175,7 +202,7 @@ const Snippetbar = createClass({
/>;
})
}
</div>;
</ul>;
},
replaceContent : function(item){
@@ -220,53 +247,57 @@ const Snippetbar = createClass({
return (
<div className='editors'>
{this.props.view !== 'meta' && <><div className='historyTools'>
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
<button className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
onClick={this.toggleHistoryMenu} >
<i className='fas fa-clock-rotate-left' />
{ this.state.showHistory && this.renderHistoryItems() }
</div>
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
</button>
<button className={`editorTool undo ${this.props.historySize.done ? 'active' : ''}`}
onClick={this.props.undo} >
<i className='fas fa-undo' />
</div>
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
</button>
<button className={`editorTool redo ${this.props.historySize.undone ? 'active' : ''}`}
onClick={this.props.redo} >
<i className='fas fa-redo' />
</div>
</button>
</div>
<div className='codeTools'>
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
<button className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
onClick={this.props.foldCode} >
<i className='fas fa-compress-alt' />
</div>
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
</button>
<button className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
onClick={this.props.unfoldCode} >
<i className='fas fa-expand-alt' />
</div>
<div className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
</button>
<button className={`editorTool formatCode ${this.props.formatCode ? 'active' : ''}`}
onClick={this.props.formatCode} >
<i className='fas fa-wand-magic-sparkles' />
</button>
<button className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
onClick={this.toggleThemeSelector} >
<i className='fas fa-palette' />
{this.state.themeSelector && this.renderThemeSelector()}
</div>
</button>
</div></>}
<div className='tabs'>
<div className={cx('text', { selected: this.props.view === 'text' })}
<button className={cx('text', { selected: this.props.view === 'text' })}
onClick={()=>this.props.onViewChange('text')}>
<i className='fa fa-beer' />
</div>
<div className={cx('style', { selected: this.props.view === 'style' })}
</button>
<button className={cx('style', { selected: this.props.view === 'style' })}
onClick={()=>this.props.onViewChange('style')}>
<i className='fa fa-paint-brush' />
</div>
<div className={cx('snippet', { selected: this.props.view === 'snippet' })}
</button>
<button className={cx('snippet', { selected: this.props.view === 'snippet' })}
onClick={()=>this.props.onViewChange('snippet')}>
<i className='fas fa-th-list' />
</div>
<div className={cx('meta', { selected: this.props.view === 'meta' })}
</button>
<button className={cx('meta', { selected: this.props.view === 'meta' })}
onClick={()=>this.props.onViewChange('meta')}>
<i className='fas fa-info-circle' />
</div>
</button>
</div>
</div>
@@ -281,9 +312,9 @@ const Snippetbar = createClass({
}
});
module.exports = Snippetbar;
export default Snippetbar;
const SnippetGroup = createClass({
const SnippetGroup = createReactClass({
displayName : 'SnippetGroup',
getDefaultProps : function() {
return {
@@ -295,36 +326,35 @@ const SnippetGroup = createClass({
};
},
handleSnippetClick : function(e, snippet){
e.stopPropagation();
this.props.onSnippetClick(execute(snippet.gen, this.props));
},
renderSnippets : function(snippets){
return _.map(snippets, (snippet)=>{
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}>
if(!snippet.subsnippets){
return (
<li key={snippet.name} role='none'>
<button className='menu-item' onClick={(e)=>this.handleSnippetClick(e, snippet)} role='menuitem' aria-label={snippet.name} disabled={snippet.disabled}>
<i className={snippet.icon} />
<span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span>
{snippet.experimental && <span className='beta'>beta</span>}
{snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
{snippet.subsnippets && <>
<i className='fas fa-caret-right'></i>
<div className='dropdown side'>
{snippet.experimental && <span className='status'>beta</span>}
{snippet.disabled && <span className='status' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
</button>
</li>
);
} else if(snippet.subsnippets){
return (
<Dropdown groupName={snippet.name} icon={snippet.icon} key={snippet.name}>
{this.renderSnippets(snippet.subsnippets)}
</div></>}
</div>;
</Dropdown>
)
}
});
},
render : function(){
const snippetGroup = `snippetGroup snippetBarButton ${this.props.snippets.length === 0 ? 'disabledSnippets' : ''}`;
return <div className={snippetGroup}>
<div className='text'>
<i className={this.props.icon} />
<span className='groupName'>{this.props.groupName}</span>
</div>
<div className='dropdown'>
return <Dropdown groupName={this.props.groupName} id={this.props.groupName} icon={this.props.icon}>
{this.renderSnippets(this.props.snippets)}
</div>
</div>;
</Dropdown>;
},
});
@@ -1,28 +1,31 @@
@import '@sharedStyles/core.less';
@import (less) './client/icons/customIcons.less';
@import (less) '././././themes/fonts/5e/fonts.less';
@import (less) '@themes/fonts/5e/fonts.less';
.snippetBar {
--activeTriggerColor: inherit;
--menuColor : #DDDDDD;
@menuHeight : 25px;
position : relative;
display : flex;
flex-wrap : wrap-reverse;
reading-flow : flex-visual;
justify-content : space-between;
height : auto;
color : black;
background-color : #DDDDDD;
.snippets {
display : flex;
justify-content : flex-start;
min-width : 432.18px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
}
font-size : .65rem;
font-family: 'Open Sans', sans-serif;
text-transform: uppercase;
font-weight: 800;
.editors {
display : flex;
justify-content : flex-end;
min-width : 250px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
min-width : 275px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
font-size: .85rem;
&:only-child {min-width : unset; margin-left : auto;}
reading-order : 2;
>div {
display : flex;
@@ -31,7 +34,7 @@
&:first-child { border-left : none; }
& > div {
& > button {
position : relative;
width : @menuHeight;
height : @menuHeight;
@@ -56,32 +59,32 @@
}
&.undo {
.tooltipLeft('Undo');
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
}
&.redo {
.tooltipLeft('Redo');
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
}
&.foldAll {
.tooltipLeft('Fold All');
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
}
&.unfoldAll {
.tooltipLeft('Unfold All');
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
}
&.formatCode {
.tooltipLeft('Clean your Code');
color : grey;
&.active { color : inherit; }
}
&.history {
.tooltipLeft('History');
position : relative;
font-size : 0.75em;
color : grey;
border : none;
&.active { color : inherit; }
@@ -92,7 +95,6 @@
}
&.editorTheme {
.tooltipLeft('Editor Themes');
font-size : 0.75em;
color : inherit;
&.active {
position : relative;
@@ -119,23 +121,7 @@
}
}
}
.snippetBarButton {
display : inline-block;
height : @menuHeight;
padding : 0px 5px;
font-size : 0.625em;
font-weight : 800;
line-height : @menuHeight;
text-transform : uppercase;
text-wrap : nowrap;
cursor : pointer;
&:hover, &.selected { background-color : #999999; }
i {
margin-right : 3px;
font-size : 1.4em;
vertical-align : middle;
}
}
.toggleMeta {
position : absolute;
top : 0px;
@@ -143,37 +129,44 @@
border-left : 1px solid black;
.tooltipLeft('Edit Brew Properties');
}
.snippetGroup {
&:hover {
& > .dropdown { visibility : visible; }
.snippets {
display : flex;
justify-content : flex-start;
min-width : 565.95px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
reading-order : 1;
}
.dropdown {
position : absolute;
top : 100%;
z-index : 1000;
visibility : hidden;
padding : 0px;
margin-left : -5px;
background-color : #DDDDDD;
.snippet {
// removed caret for top level items, by request (makes buttons too wide).
.menu-wrapper .menu-item:is(.snippets > .menu-wrapper > .menu-item):first-child .caret { display: none; }
.menu-item {
position : relative;
display : flex;
justify-content: space-between;
align-items : center;
min-width : max-content;
padding : 5px;
font-size : 10px;
cursor : pointer;
.animate(background-color);
width: 100%;
&:is(.menu-list .menu-item) [class*="name"] {
padding-inline: 8px; // additional space between icon and name (helpful in Fonts menu especially).
}
.menu-name {
flex: 1;
text-align: left;
text-box-trim: trim-end;
}
i {
min-width : 25px;
height : 1.2em;
margin-right : 8px;
height : .85rem;
font-size : 1.2em;
text-align : center;
& ~ i {
&.caret {
margin-right: 0;
margin-left : 5px;
}
&.caret:is(.menu-wrapper .menu-wrapper * ) {
text-align: right;
}
/* Fonts */
&.font {
@@ -203,8 +196,7 @@
}
}
.name { margin-right : auto; }
.disabled { text-decoration : line-through; }
.beta {
.status {
align-self : center;
padding : 4px 6px;
margin-left : 5px;
@@ -216,37 +208,26 @@
}
&:hover {
background-color : #999999;
& > .dropdown {
visibility : visible;
&.side {
top : 0%;
left : 100%;
margin-left : 0;
box-shadow : -1px 1px 2px 0px #999999;
}
}
}
}
}
}
.disabledSnippets {
color: grey;
&:disabled {
color: gray;
cursor: not-allowed;
&:hover { background-color: #DDDDDD;}
&:hover { background-color: unset; }
}
}
@container editor (width < 683px) {
}
@container editor (width < 841px) {
.snippetBar {
.editors {
flex : 1;
justify-content : space-between;
border-bottom : 1px solid;
reading-order : 1;
}
.snippets {
flex : 1;
justify-content : space-evenly;
reading-order : 2;
}
.editors > div.history > .dropdown { right : unset; }
}
@@ -0,0 +1,219 @@
export const tagSuggestionList = [
// ############################## Systems
// D&D
'system:D&D Original',
'system:D&D Basic',
'system:AD&D 1e',
'system:AD&D 2e',
'system:D&D 3e',
'system:D&D 3.5e',
'system:D&D 4e',
'system:D&D 5e',
'system:D&D 5e 2024',
'system:BD&D (B/X)',
'system:D&D Essentials',
// Other Famous RPGs
'system:Pathfinder 1e',
'system:Pathfinder 2e',
'system:Vampire: The Masquerade',
'system:Werewolf: The Apocalypse',
'system:Mage: The Ascension',
'system:Call of Cthulhu',
'system:Shadowrun',
'system:Star Wars RPG (D6/D20/Edge of the Empire)',
'system:Warhammer Fantasy Roleplay',
'system:Cyberpunk 2020',
'system:Blades in the Dark',
'system:Daggerheart',
'system:Draw Steel',
'system:Mutants and Masterminds',
// Meta
'meta:V3',
'meta:Legacy',
'meta:Template',
'meta:Theme',
'meta:free',
'meta:Character Sheet',
'meta:Documentation',
'meta:NPC',
'meta:Guide',
'meta:Resource',
'meta:Notes',
'meta:Example',
// Book type
'type:Campaign',
'type:Campaign Setting',
'type:Adventure',
'type:One-Shot',
'type:Setting',
'type:World',
'type:Lore',
'type:History',
'type:Dungeon Master',
'type:Encounter Pack',
'type:Encounter',
'type:Session Notes',
'type:reference',
'type:Handbook',
'type:Manual',
'type:Manuals',
'type:Compendium',
'type:Bestiary',
// ###################################### RPG Keywords
// Classes / Subclasses / Archetypes
'Class',
'Subclass',
'Archetype',
'Martial',
'Half-Caster',
'Full Caster',
'Artificer',
'Barbarian',
'Bard',
'Cleric',
'Druid',
'Fighter',
'Monk',
'Paladin',
'Rogue',
'Sorcerer',
'Warlock',
'Wizard',
// Races / Species / Lineages
'Race',
'Ancestry',
'Lineage',
'Aasimar',
'Beastfolk',
'Dragonborn',
'Dwarf',
'Elf',
'Goblin',
'Half-Elf',
'Half-Orc',
'Human',
'Kobold',
'Lizardfolk',
'Lycan',
'Orc',
'Tiefling',
'Vampire',
'Yuan-Ti',
// Magic / Spells / Items
'Magic',
'Magic Item',
'Magic Items',
'Wondrous Item',
'Magic Weapon',
'Artifact',
'Spell',
'Spells',
'Cantrip',
'Cantrips',
'Eldritch',
'Eldritch Invocation',
'Invocation',
'Invocations',
'Pact boon',
'Pact Boon',
'Spellcaster',
'Spellblade',
'Magical Tattoos',
'Enchantment',
'Enchanted',
'Attunement',
'Requires Attunement',
'Rune',
'Runes',
'Wand',
'Rod',
'Scroll',
'Potion',
'Potions',
'Item',
'Items',
'Bag of Holding',
// Monsters / Creatures / Enemies
'Monster',
'Creatures',
'Creature',
'Beast',
'Beasts',
'Humanoid',
'Undead',
'Fiend',
'Aberration',
'Ooze',
'Giant',
'Dragon',
'Monstrosity',
'Demon',
'Devil',
'Elemental',
'Construct',
'Constructs',
'Boss',
'BBEG',
// ############################# Media / Pop Culture
'One Piece',
'Dragon Ball',
'Dragon Ball Z',
'Naruto',
'Jujutsu Kaisen',
'Fairy Tail',
'Final Fantasy',
'Kingdom Hearts',
'Elder Scrolls',
'Skyrim',
'WoW',
'World of Warcraft',
'Marvel Comics',
'DC Comics',
'Pokemon',
'League of Legends',
'Runeterra',
'Arcane',
'Yu-Gi-Oh',
'Minecraft',
'Don\'t Starve',
'Witcher',
'Witcher 3',
'Cyberpunk',
'Cyberpunk 2077',
'Fallout',
'Divinity Original Sin 2',
'Fullmetal Alchemist',
'Fullmetal Alchemist Brotherhood',
'Lobotomy Corporation',
'Bloodborne',
'Dragonlance',
'Shackled City Adventure Path',
'Baldurs Gate 3',
'Library of Ruina',
'Radiant Citadel',
'Ravenloft',
'Forgotten Realms',
'Exandria',
'Critical Role',
'Star Wars',
'SW5e',
'Star Wars 5e',
];
// substrings to be normalized to the first value on the array
export const canonizationList = [
['5e 2024', '5.5e', '5e\'24', '5.24', '5e24', '5.5'],
['5e', '5th Edition'],
['Dungeons & Dragons', 'Dungeons and Dragons', 'Dungeons n dragons'],
['D&D', 'DnD', 'dnd', 'Dnd', 'dnD', 'd&d', 'd&D', 'D&d'],
['P2e', 'p2e', 'P2E', 'Pathfinder 2e'],
];
+175 -81
View File
@@ -1,105 +1,199 @@
require('./tagInput.less');
const React = require('react');
const { useState, useEffect } = React;
const _ = require('lodash');
import './tagInput.less';
import React, { useState, useEffect } from 'react';
import Combobox from '@components/combobox.jsx';
const TagInput = ({ unique = true, values = [], ...props })=>{
const [tempInputText, setTempInputText] = useState('');
const [tagList, setTagList] = useState(values.map((value)=>({ value, editing: false })));
import { tagSuggestionList, canonizationList } from './curatedTagSuggestionList.js';
const TagInput = ({ tooltip, label, valuePatterns, values = [], unique = true, placeholder = '', smallText = '', onChange })=>{
const [tagList, setTagList] = useState(
values.map((value)=>({
value,
editing : false,
draft : '',
})),
);
useEffect(()=>{
handleChange(tagList.map((context)=>context.value));
const incoming = values || [];
const current = tagList.map((t)=>t.value);
const changed = incoming.length !== current.length || incoming.some((v, i)=>v !== current[i]);
if(changed) {
setTagList(
incoming.map((value)=>({
value,
editing : false,
})),
);
}
}, [values]);
useEffect(()=>{
onChange?.({
target : { value: tagList.map((t)=>t.value) },
});
}, [tagList]);
const handleChange = (value)=>{
props.onChange({
target : { value }
const normalizeValue = (input)=>{
const lowerInput = input.toLowerCase();
let normalizedTag = input;
for (const group of canonizationList) {
for (const tag of group) {
if(!tag) continue;
const index = lowerInput.indexOf(tag.toLowerCase());
if(index !== -1) {
normalizedTag = input.slice(0, index) + group[0] + input.slice(index + tag.length);
break;
}
}
}
if(normalizedTag.includes(':')) {
const [rawType, rawValue = ''] = normalizedTag.split(':');
const tagType = rawType.trim().toLowerCase();
const tagValue = rawValue.trim();
if(tagValue.length > 0) {
normalizedTag = `${tagType}:${tagValue[0].toUpperCase()}${tagValue.slice(1)}`;
}
//trims spaces around colon and capitalizes the first word after the colon
//this is preferred to users not understanding they can't put spaces in
}
return normalizedTag;
};
const submitTag = (newValue, index = null)=>{
const trimmed = newValue?.trim();
if(!trimmed) return;
if(!valuePatterns.test(trimmed)) return;
const normalizedTag = normalizeValue(trimmed);
setTagList((prev)=>{
const existsIndex = prev.findIndex((t)=>t.value.toLowerCase() === normalizedTag.toLowerCase());
if(unique && existsIndex !== -1) return prev;
if(index !== null) {
return prev.map((t, i)=>(i === index ? { ...t, value: normalizedTag, editing: false } : t));
}
return [...prev, { value: normalizedTag, editing: false }];
});
};
const handleInputKeyDown = ({ evt, value, index, options = {} })=>{
if(_.includes(['Enter', ','], evt.key)) {
evt.preventDefault();
submitTag(evt.target.value, value, index);
if(options.clear) {
setTempInputText('');
}
}
};
const submitTag = (newValue, originalValue, index)=>{
setTagList((prevContext)=>{
// remove existing tag
if(newValue === null){
return [...prevContext].filter((context, i)=>i !== index);
}
// add new tag
if(originalValue === null){
return [...prevContext, { value: newValue, editing: false }];
}
// update existing tag
return prevContext.map((context, i)=>{
if(i === index) {
return { ...context, value: newValue, editing: false };
}
return context;
});
});
const removeTag = (index)=>{
setTagList((prev)=>prev.filter((_, i)=>i !== index));
};
const editTag = (index)=>{
setTagList((prevContext)=>{
return prevContext.map((context, i)=>{
if(i === index) {
return { ...context, editing: true };
setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: true, draft: t.value } : t)));
};
const stopEditing = (index)=>{
setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: false, draft: '' } : t)));
};
const suggestionOptions = tagSuggestionList.map((tag)=>{
const tagType = tag.split(':');
let classes = 'item';
switch (tagType[0]) {
case 'type':
classes = 'item type';
break;
case 'group':
classes = 'item group';
break;
case 'meta':
classes = 'item meta';
break;
case 'system':
classes = 'item system';
break;
default:
classes = 'item';
break;
}
return { ...context, editing: false };
});
});
};
const renderReadTag = (context, index)=>{
return (
<li key={index}
data-value={context.value}
className='tag'
onClick={()=>editTag(index)}>
{context.value}
<button onClick={(evt)=>{evt.stopPropagation(); submitTag(null, context.value, index);}}><i className='fa fa-times fa-fw'/></button>
</li>
<div className={classes} key={`tag-${tag}`} value={tag} data={tag}>
{tag}
</div>
);
};
});
const renderWriteTag = (context, index)=>{
return (
<input type='text'
key={index}
defaultValue={context.value}
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: context.value, index: index })}
<div className='tagInputWrap'>
<Combobox
trigger='click'
className='tagInput-dropdown'
default=''
placeholder={placeholder}
options={label === 'tags' ? suggestionOptions : []}
tooltip={tooltip}
autoSuggest={
label === 'tags'
? {
suggestMethod : 'startsWith',
clearAutoSuggestOnClick : true,
filterOn : ['value', 'title'],
}
: { suggestMethod: 'includes', clearAutoSuggestOnClick: true, filterOn: [] }
}
valuePatterns={valuePatterns.source}
onSelect={(value)=>submitTag(value)}
onEntry={(e)=>{
if(e.key === 'Enter') {
e.preventDefault();
submitTag(e.target.value);
}
}}
/>
<ul className='list'>
{tagList.map((t, i)=>t.editing ? (
<input
key={i}
type='text'
value={t.draft} // always use draft
pattern={valuePatterns.source}
onChange={(e)=>setTagList((prev)=>prev.map((tag, idx)=>(idx === i ? { ...tag, draft: e.target.value } : tag)),
)
}
onKeyDown={(e)=>{
if(e.key === 'Enter') {
e.preventDefault();
submitTag(t.draft, i); // submit draft
setTagList((prev)=>prev.map((tag, idx)=>(idx === i ? { ...tag, draft: '' } : tag)),
);
}
if(e.key === 'Escape') {
stopEditing(i);
e.target.blur();
}
}}
autoFocus
/>
);
};
return (
<div className='field'>
<label>{props.label}</label>
<div className='value'>
<ul className='list'>
{tagList.map((context, index)=>{ return context.editing ? renderWriteTag(context, index) : renderReadTag(context, index); })}
) : (
<li key={i} className='tag' onClick={()=>editTag(i)}>
{t.value}
<button
type='button'
aria-label={`remove ${t.value} tag`}
onClick={(e)=>{
e.stopPropagation();
removeTag(i);
}}>
<i className='fa fa-times fa-fw' />
</button>
</li>
),
)}
</ul>
<input
type='text'
className='value'
placeholder={props.placeholder}
value={tempInputText}
onChange={(e)=>setTempInputText(e.target.value)}
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: null, options: { clear: true } })}
/>
</div>
</div>
);
};
module.exports = TagInput;
export default TagInput;
@@ -0,0 +1,31 @@
.tags {
.tagInputWrap {
display:grid;
grid-template-columns: 200px 3fr;
gap:10px;
}
.list input {
border-radius: 5px;
}
.tagInput-dropdown {
.dropdown-options {
.item {
&.type {
background-color: #00800035;
}
&.group {
background-color: #50505035;
}
&.meta {
background-color: #00008035;
}
&.system {
background-color: #80000035;
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+34 -11
View File
@@ -1,8 +1,9 @@
/* eslint-disable camelcase */
import 'core-js/es/string/to-well-formed.js'; // Polyfill for older browsers
import './homebrew.less';
import React from 'react';
import { StaticRouter as Router, Route, Routes, useParams, useSearchParams } from 'react-router';
import { BrowserRouter as Router, Routes, Route, useParams, useSearchParams } from 'react-router';
import { updateLocalStorage } from './utils/updateLocalStorage/updateLocalStorageKeys.js';
import HomePage from './pages/homePage/homePage.jsx';
import EditPage from './pages/editPage/editPage.jsx';
@@ -17,7 +18,6 @@ const WithRoute = ({ el: Element, ...rest })=>{
const params = useParams();
const [searchParams] = useSearchParams();
const queryParams = Object.fromEntries(searchParams?.entries() || []);
return <Element {...rest} {...params} query={queryParams} />;
};
@@ -26,8 +26,6 @@ const Homebrew = (props)=>{
url = '',
version = '0.0.0',
account = null,
enable_v3 = false,
enable_themes,
config,
brew = {
title : '',
@@ -39,18 +37,43 @@ const Homebrew = (props)=>{
lang : ''
},
userThemes,
brews
brews,
enablev4
} = props;
global.account = account;
global.version = version;
global.enable_v3 = enable_v3;
global.enable_themes = enable_themes;
global.config = config;
global.enablev4 = enablev4;
const backgroundObject = ()=>{
if(config?.deployment || (config?.local && config?.development)) {
const bgText = config?.deployment || 'Local';
return {
backgroundImage : `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='100px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${bgText}</text></svg>")`
};
}
return null;
};
updateLocalStorage();
if(brew.pureError) {
return (
<Router>
<div className={`homebrew${(config?.deployment || config?.local) ? ' deployment' : ''}`} style={backgroundObject()}>
<Routes>
<Route path={brew.originalUrl} element={<WithRoute el={ErrorPage} brew={brew} />} />
</Routes>
</div>
</Router>
);
}
return (
<Router location={url}>
<div className='homebrew'>
<Router>
<div className={`homebrew${(config?.deployment || config?.local) ? ' deployment' : ''}`} style={backgroundObject()}>
<Routes>
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={brew} userThemes={userThemes}/>} />
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={brew} />} />
@@ -72,4 +95,4 @@ const Homebrew = (props)=>{
);
};
module.exports = Homebrew;
export default Homebrew;
+4 -2
View File
@@ -1,12 +1,14 @@
@import 'naturalcrit/styles/core.less';
@import '@sharedStyles/core.less';
.homebrew {
height : 100%;
background-color:@steel;
&.deployment { background-color : darkred; }
.sitePage {
display : flex;
flex-direction : column;
height : 100%;
overflow-y : hidden;
background-color : @steel;
.content {
position : relative;
flex : auto;
+8
View File
@@ -0,0 +1,8 @@
import { createRoot } from 'react-dom/client';
import Homebrew from './homebrew.jsx';
import { bootstrapAnchorPositioningPolyfill } from '@components/anchorPositioningPolyfill.js';
const props = window.__INITIAL_PROPS__ || {};
createRoot(document.getElementById('reactRoot')).render(<Homebrew {...props} />);
bootstrapAnchorPositioningPolyfill();
+8 -8
View File
@@ -1,9 +1,9 @@
const React = require('react');
const createClass = require('create-react-class');
const Nav = require('naturalcrit/nav/nav.jsx');
const request = require('superagent');
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
import Nav from './nav.jsx';
const Account = createClass({
const Account = createReactClass({
displayName : 'AccountNavItem',
getInitialState : function() {
return {
@@ -70,7 +70,7 @@ const Account = createClass({
{global.account.username}
</Nav.item>
<Nav.item
href={`/user/${encodeURI(global.account.username)}`}
href={`/user/${encodeURIComponent(global.account.username)}`}
color='yellow'
icon='fas fa-beer'
>
@@ -97,7 +97,7 @@ const Account = createClass({
// Logged out
// LOCAL ONLY
if(global.config.local) {
if(global.config?.local) {
return <Nav.item color='teal' icon='fas fa-sign-in-alt' onClick={this.localLogin}>
login
</Nav.item>;
@@ -111,4 +111,4 @@ const Account = createClass({
}
});
module.exports = Account;
export default Account;
+18 -28
View File
@@ -1,39 +1,21 @@
require('./error-navitem.less');
const React = require('react');
const Nav = require('naturalcrit/nav/nav.jsx');
const createClass = require('create-react-class');
import './error-navitem.less';
import React from 'react';
import Nav from './nav.jsx';
const ErrorNavItem = createClass({
getDefaultProps : function() {
return {
error : '',
parent : null
};
},
render : function() {
const clearError = ()=>{
const state = {
error : null
};
if(this.props.parent.state.isSaving) {
state.isSaving = false;
}
this.props.parent.setState(state);
};
const error = this.props.error;
const ErrorNavItem = ({ error = '', clearError })=>{
const response = error.response;
const errorCode = error.code;
const status = response?.status;
const errorCode = error.code
const HBErrorCode = response?.body?.HBErrorCode;
const message = response?.body?.message;
let errMsg = '';
try {
errMsg += `${error.toString()}\n\n`;
errMsg += `\`\`\`\n${error.stack}\n`;
errMsg += `${JSON.stringify(response?.error, null, ' ')}\n\`\`\``;
console.log(errMsg);
} catch (e){}
} catch {}
if(status === 409) {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
@@ -130,6 +112,15 @@ const ErrorNavItem = createClass({
</Nav.item>;
}
if(HBErrorCode === '13') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Server has lost connection to the database.
</div>
</Nav.item>;
}
if(errorCode === 'ECONNABORTED') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
@@ -151,7 +142,6 @@ const ErrorNavItem = createClass({
</a>.
</div>
</Nav.item>;
}
});
};
module.exports = ErrorNavItem;
export default ErrorNavItem;
@@ -1,3 +1,5 @@
@import '@sharedStyles/core.less';
.navItem.error {
position : relative;
background-color : @red;
+4 -4
View File
@@ -1,9 +1,9 @@
const React = require('react');
const dedent = require('dedent-tabs').default;
import React from 'react';
import dedent from 'dedent';
const Nav = require('naturalcrit/nav/nav.jsx');
import Nav from './nav.jsx';
module.exports = function(props){
export default function(props){
return <Nav.dropdown>
<Nav.item color='grey' icon='fas fa-question-circle'>
need help?
+7 -16
View File
@@ -1,11 +1,11 @@
const React = require('react');
const createClass = require('create-react-class');
const Moment = require('moment');
import React from 'react';
import createReactClass from 'create-react-class';
import Moment from 'moment';
const Nav = require('naturalcrit/nav/nav.jsx');
import Nav from './nav.jsx';
const MetadataNav = createClass({
const MetadataNav = createReactClass({
displayName : 'MetadataNav',
getDefaultProps : function() {
return {
@@ -32,7 +32,7 @@ const MetadataNav = createClass({
return <>
{this.props.brew.authors.map((author, idx, arr)=>{
const spacer = arr.length - 1 == idx ? <></> : <span>, </span>;
return <span key={idx}><a className='userPageLink' href={`/user/${author}`}>{author}</a>{spacer}</span>;
return <span key={idx}><a className='userPageLink' href={`/user/${encodeURIComponent(author)}`}>{author}</a>{spacer}</span>;
})}
</>;
},
@@ -46,11 +46,6 @@ const MetadataNav = createClass({
</>;
},
getSystems : function(){
if(!this.props.brew.systems || this.props.brew.systems.length == 0) return 'No systems';
return this.props.brew.systems.join(', ');
},
renderMetaWindow : function(){
return <div className={`window ${this.state.showMetaWindow ? 'active' : 'inactive'}`}>
<div className='row'>
@@ -65,10 +60,6 @@ const MetadataNav = createClass({
<h4>Tags</h4>
<p>{this.getTags()}</p>
</div>
<div className='row'>
<h4>Systems</h4>
<p>{this.getSystems()}</p>
</div>
<div className='row'>
<h4>Updated</h4>
<p>{Moment(this.props.brew.updatedAt).fromNow()}</p>
@@ -86,4 +77,4 @@ const MetadataNav = createClass({
});
module.exports = MetadataNav;
export default MetadataNav;
@@ -1,22 +1,16 @@
require('client/homebrew/navbar/navbar.less');
const React = require('react');
const { useState, useRef, useEffect } = React;
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
import './navbar.less';
import React, { useState, useRef, useEffect } from 'react';
import cx from 'classnames';
const NaturalCritIcon = require('naturalcrit/svg/naturalcrit.svg.jsx');
import NaturalCritIcon from '@components/svg/naturalcrit-d20.svg.jsx';
const Nav = {
base : createClass({
displayName : 'Nav.base',
render : function(){
return <nav>
{this.props.children}
base : ({ children, className, ...props })=>{
return <nav className={className}>
{children}
</nav>;
}
}),
logo : function(){
},
logo : ()=>{
return <a className='navLogo' href='https://www.naturalcrit.com'>
<NaturalCritIcon />
<span className='name'>
@@ -25,50 +19,26 @@ const Nav = {
</a>;
},
section : createClass({
displayName : 'Nav.section',
render : function(){
return <div className={`navSection ${this.props.className ?? ''}`}>
{this.props.children}
section : ({ children, className, ...props })=>{
return <div className={cx([`navSection`, className])}>
{children}
</div>;
}
}),
item : createClass({
displayName : 'Nav.item',
getDefaultProps : function() {
return {
icon : null,
href : null,
newTab : false,
onClick : function(){},
color : null
};
},
handleClick : function(e){
this.props.onClick(e);
},
render : function(){
const classes = cx('navItem', this.props.color, this.props.className);
let icon;
if(this.props.icon) icon = <i className={this.props.icon} />;
const props = _.omit(this.props, ['newTab']);
if(this.props.href){
return <a {...props} className={classes} target={this.props.newTab ? '_blank' : '_self'} >
{this.props.children}
{icon}
item : ({ icon, href, newTab, onClick, color, children, className, ...props })=>{
const classes = cx('navItem', color, className);
if(href){
return <a className={classes} href={href} target={newTab ? '_blank' : '_self'} {...props}>
{children}
{icon && <i className={icon}></i>}
</a>;
} else {
return <div {...props} className={classes} onClick={this.handleClick} >
{this.props.children}
{icon}
</div>;
return <button {...props} className={classes} onClick={onClick} >
{children}
{icon && <i className={icon}></i>}
</button>;
}
}
}),
},
dropdown : function dropdown(props) {
props = Object.assign({}, props, {
@@ -117,4 +87,4 @@ const Nav = {
};
module.exports = Nav;
export default Nav;
+8 -26
View File
@@ -1,36 +1,18 @@
require('./navbar.less');
const React = require('react');
const createClass = require('create-react-class');
import './navbar.less';
import React from 'react';
import createReactClass from 'create-react-class';
const Nav = require('naturalcrit/nav/nav.jsx');
const PatreonNavItem = require('./patreon.navitem.jsx');
import Nav from './nav.jsx';
import PatreonNavItem from './patreon.navitem.jsx';
const Navbar = createClass({
const Navbar = createReactClass({
displayName : 'Navbar',
getInitialState : function() {
return {
//showNonChromeWarning : false,
ver : '0.0.0'
ver : global.version || '0.0.0'
};
},
getInitialState : function() {
return {
ver : global.version
};
},
/*
renderChromeWarning : function(){
if(!this.state.showNonChromeWarning) return;
return <Nav.item className='warning' icon='fa-exclamation-triangle'>
Optimized for Chrome
<div className='dropdown'>
If you are experiencing rendering issues, use Chrome instead
</div>
</Nav.item>
},
*/
render : function(){
return <Nav.base>
<Nav.section>
@@ -49,4 +31,4 @@ const Navbar = createClass({
}
});
module.exports = Navbar;
export default Navbar;
+4 -1
View File
@@ -1,4 +1,4 @@
@import 'naturalcrit/styles/colors.less';
@import '@sharedStyles/core.less';
@navbarHeight : 28px;
@viewerToolsHeight : 32px;
@@ -39,6 +39,9 @@
flex-grow : 1;
min-width : 300px;
}
>.brewTitle {
cursor:auto;
}
}
// "NaturalCrit" logo
.navLogo {
+60 -21
View File
@@ -1,37 +1,69 @@
const React = require('react');
const _ = require('lodash');
const Nav = require('naturalcrit/nav/nav.jsx');
const { splitTextStyleAndMetadata } = require('../../../shared/helpers.js'); // Importing the function from helpers.js
import React from 'react';
import _ from 'lodash';
import Nav from './nav.jsx';
import { splitTextStyleAndMetadata } from '@shared/helpers.js';
const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style';
const METAKEY = 'homebrewery-new-meta';
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
const METAKEY = 'HB_newPage_meta';
const NewBrew = ()=>{
const handleFileChange = (e)=>{
const file = e.target.files[0];
if(file) {
if(!file) return;
if(!confirmLocalStorageChange()) return;
const reader = new FileReader();
reader.onload = (e)=>{
const fileContent = e.target.result;
const newBrew = {
text : fileContent,
style : ''
};
const newBrew = { text: fileContent, style: '' };
if(fileContent.startsWith('```metadata')) {
splitTextStyleAndMetadata(newBrew); // Modify newBrew directly
splitTextStyleAndMetadata(newBrew);
localStorage.setItem(BREWKEY, newBrew.text);
localStorage.setItem(STYLEKEY, newBrew.style);
localStorage.setItem(METAKEY, JSON.stringify(_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang'])));
localStorage.setItem(METAKEY, JSON.stringify(
_.pick(newBrew, ['title', 'description', 'tags', 'renderer', 'theme', 'lang'])
));
window.location.href = '/new';
} else {
alert('This file is invalid, please, enter a valid file');
return;
}
const type = file.name.split('.').pop().toLowerCase();
alert(`This file is invalid: ${!type ? 'Missing file extension' :`.${type} files are not supported`}. Only .txt files exported from the Homebrewery are allowed.`);
console.log(file);
};
reader.readAsText(file);
}
};
const confirmLocalStorageChange = ()=>{
const currentText = localStorage.getItem(BREWKEY);
const currentStyle = localStorage.getItem(STYLEKEY);
const currentMeta = localStorage.getItem(METAKEY);
// TRUE if no data in any local storage key
// TRUE if data in any local storage key AND approval given
// FALSE if data in any local storage key AND approval declined
return (!(currentText || currentStyle || currentMeta) || confirm(
`You have made changes in the new brew space. If you continue, that information will be PERMANENTLY LOST.\nAre you sure you wish to continue?`
));
};
const clearLocalStorage = ()=>{
if(!confirmLocalStorageChange()) return;
localStorage.removeItem(BREWKEY);
localStorage.removeItem(STYLEKEY);
localStorage.removeItem(METAKEY);
window.location.href = '/new';
return;
};
return (
<Nav.dropdown>
<Nav.item
@@ -41,17 +73,24 @@ const NewBrew = ()=>{
new
</Nav.item>
<Nav.item
className='fromBlank'
className='new'
href='/new'
newTab={true}
color='purple'
icon='fa-solid fa-file'>
resume draft
</Nav.item>
<Nav.item
className='fromBlank'
newTab={true}
color='yellow'
icon='fa-solid fa-file-circle-plus'
onClick={()=>{ clearLocalStorage(); }}>
from blank
</Nav.item>
<Nav.item
className='fromFile'
color='purple'
color='green'
icon='fa-solid fa-upload'
onClick={()=>{ document.getElementById('uploadTxt').click(); }}>
<input id='uploadTxt' className='newFromLocal' type='file' onChange={handleFileChange} style={{ display: 'none' }} />
@@ -61,4 +100,4 @@ const NewBrew = ()=>{
);
};
module.exports = NewBrew;
export default NewBrew;
+3 -3
View File
@@ -1,7 +1,7 @@
const React = require('react');
const Nav = require('naturalcrit/nav/nav.jsx');
import React from 'react';
import Nav from './nav.jsx';
module.exports = function(props){
export default function(props){
return <Nav.item
className='patreon'
newTab={true}
+21 -5
View File
@@ -1,9 +1,25 @@
const React = require('react');
const Nav = require('naturalcrit/nav/nav.jsx');
const { printCurrentBrew } = require('../../../shared/helpers.js');
import React, { useState, useEffect } from 'react';
import Nav from './nav.jsx';
import { printCurrentBrew } from '@shared/helpers.js';
export default function(){
const [printing, setPrinting] = useState(false);
// listen for print cycle events to display "loading" message since it can take some time.
useEffect(()=>{
document.addEventListener('print:startprep', handlePrintStartPrep);
document.addEventListener('print:finishedprep', handlePrintPrepFinished);
return ()=>{
document.removeEventListener('print:startprep', handlePrintStartPrep);
document.removeEventListener('print:finishedprep', handlePrintPrepFinished);
}
}, []);
const handlePrintStartPrep = ()=>{ setPrinting(true); };
const handlePrintPrepFinished = ()=>{ setPrinting(false); };
module.exports = function(){
return <Nav.item onClick={printCurrentBrew} color='purple' icon='far fa-file-pdf'>
get PDF
{printing ? 'loading' : 'get PDF'}
</Nav.item>;
};
+9 -9
View File
@@ -1,15 +1,15 @@
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const Moment = require('moment');
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import Moment from 'moment';
const Nav = require('naturalcrit/nav/nav.jsx');
import Nav from './nav.jsx';
const EDIT_KEY = 'homebrewery-recently-edited';
const VIEW_KEY = 'homebrewery-recently-viewed';
const EDIT_KEY = 'HB_nav_recentlyEdited';
const VIEW_KEY = 'HB_nav_recentlyViewed';
const RecentItems = createClass({
const RecentItems = createReactClass({
DisplayName : 'RecentItems',
getDefaultProps : function() {
return {
@@ -175,7 +175,7 @@ const RecentItems = createClass({
});
module.exports = {
export default {
edited : (props)=>{
return <RecentItems
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import dedent from 'dedent';
import Nav from './nav.jsx';
const getShareId = (brew)=>(
brew.googleId && !brew.stubbed
? brew.googleId + brew.shareId
: brew.shareId
);
const getRedditLink = (brew)=>{
const text = dedent`
Hey guys! I've been working on this homebrew. I'd love your feedback. Check it out.
**[Homebrewery Link](${global.config.baseUrl}/share/${getShareId(brew)})**`;
return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(brew.title.toWellFormed())}&text=${encodeURIComponent(text)}`;
};
export default ({ brew, currentPage })=>(
<Nav.dropdown>
<Nav.item color='teal' icon='fas fa-share-alt'>
share
</Nav.item>
<Nav.item color='blue' href={`/share/${getShareId(brew)}`}>
view
</Nav.item>
<Nav.item color='blue' onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${getShareId(brew)}`);}}>
copy url
</Nav.item>
{currentPage > 1 &&
<Nav.item
color='blue'
onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${getShareId(brew)}#p${currentPage}`);}}>
copy url (page {currentPage})
</Nav.item>}
<Nav.item color='blue' href={getRedditLink(brew)} newTab rel='noopener noreferrer'>
post to reddit
</Nav.item>
</Nav.dropdown>
);
+3 -3
View File
@@ -1,8 +1,8 @@
const React = require('react');
import React from 'react';
const Nav = require('naturalcrit/nav/nav.jsx');
import Nav from './nav.jsx';
module.exports = function (props) {
export default function (props) {
return (
<Nav.item
color='purple'
@@ -1,7 +1,7 @@
const React = require('react');
const moment = require('moment');
const UIPage = require('../basePages/uiPage/uiPage.jsx');
const NaturalCritIcon = require('naturalcrit/svg/naturalcrit.svg.jsx');
import React from 'react';
import moment from 'moment';
import UIPage from '../basePages/uiPage/uiPage.jsx';
import NaturalCritIcon from '@components/svg/naturalcrit-d20.svg.jsx';
let SAVEKEY = '';
@@ -13,7 +13,7 @@ const AccountPage = (props)=>{
// initialize save location from local storage based on user id
React.useEffect(()=>{
if(!saveLocation && accountDetails.username) {
SAVEKEY = `HOMEBREWERY-DEFAULT-SAVE-LOCATION-${accountDetails.username}`;
SAVEKEY = `HB_editor_defaultSave_${accountDetails.username}`;
// if no SAVEKEY in local storage, default save location to Google Drive if user has Google account.
let saveLocation = window.localStorage.getItem(SAVEKEY);
saveLocation = saveLocation ?? (accountDetails.googleId ? 'GOOGLE-DRIVE' : 'HOMEBREWERY');
@@ -79,4 +79,4 @@ const AccountPage = (props)=>{
</UIPage>);
};
module.exports = AccountPage;
export default AccountPage;
@@ -1,12 +1,11 @@
require('./brewItem.less');
const React = require('react');
const { useCallback } = React;
const moment = require('moment');
import './brewItem.less';
import React, { useCallback } from 'react';
import moment from 'moment';
import request from '../../../../utils/request-middleware.js';
const googleDriveIcon = require('../../../../googleDrive.svg');
const homebreweryIcon = require('../../../../thumbnail.svg');
const dedent = require('dedent-tabs').default;
import googleDriveIcon from '../../../../googleDrive.svg';
import homebreweryIcon from '../../../../thumbnail.svg';
import dedent from 'dedent';
const BrewItem = ({
brew = {
@@ -40,9 +39,9 @@ const BrewItem = ({
if(!brew.editId) return null;
return (
<a className='deleteLink' onClick={deleteBrew}>
<i className='fas fa-trash-alt' title='Delete' />
</a>
<button aria-label={`Delete ${brew.title}`} className='deleteLink' onClick={deleteBrew}>
<i className='fas fa-trash-alt' aria-hidden='true' title='Delete' />
</button>
);
};
@@ -53,7 +52,7 @@ const BrewItem = ({
if(brew.googleId && !brew.stubbed) editLink = brew.googleId + editLink;
return (
<a className='editLink' href={`/edit/${editLink}`} target='_blank' rel='noopener noreferrer'>
<a className='editLink' href={`/edit/${editLink}`} aria-label={`Edit ${brew.title}`} target='_blank' rel='noopener noreferrer'>
<i className='fas fa-pencil-alt' title='Edit' />
</a>
);
@@ -68,7 +67,7 @@ const BrewItem = ({
}
return (
<a className='shareLink' href={`/share/${shareLink}`} target='_blank' rel='noopener noreferrer'>
<a className='shareLink' href={`/share/${shareLink}`} aria-label={`Share ${brew.title}`} target='_blank' rel='noopener noreferrer'>
<i className='fas fa-share-alt' title='Share' />
</a>
);
@@ -83,7 +82,7 @@ const BrewItem = ({
}
return (
<a className='downloadLink' href={`/download/${shareLink}`}>
<a className='downloadLink' aria-label={`Download ${brew.title}`} href={`/download/${shareLink}`}>
<i className='fas fa-download' title='Download' />
</a>
);
@@ -95,7 +94,7 @@ const BrewItem = ({
return (
<span title={brew.webViewLink ? 'Your Google Drive Storage' : 'Another User\'s Google Drive Storage'}>
<a href={brew.webViewLink} target='_blank'>
<img className='googleDriveIcon' src={googleDriveIcon} alt='googleDriveIcon' />
<img className='googleDriveIcon' src={googleDriveIcon} alt='Google Drive Storage' />
</a>
</span>
);
@@ -103,7 +102,7 @@ const BrewItem = ({
return (
<span title='Homebrewery Storage'>
<img className='homebreweryIcon' src={homebreweryIcon} alt='homebreweryIcon' />
<img className='homebreweryIcon' src={homebreweryIcon} alt='Homebrewery Storage' />
</span>
);
};
@@ -143,25 +142,26 @@ const BrewItem = ({
<span title="Username contained an email address; hidden to protect user's privacy">
{author}
</span>
) : (<a href={`/user/${author}`}>{author}</a>)}
) : (<a href={`/user/${encodeURIComponent(author)}`}>{author}</a>)}
{index < brew.authors.length - 1 && ', '}
</React.Fragment>
))}
</span>
<br />
<span title={`Last viewed: ${moment(brew.lastViewed).local().format(dateFormatString)}`}>
<i className='fas fa-eye' /> {brew.views}
<span aria-label={`Viewed ${brew.views} times`} title={`Last viewed: ${moment(brew.lastViewed).local().format(dateFormatString)}`}>
<span aria-hidden='true'><i className='fas fa-eye' /> {brew.views}</span>
</span>
{brew.pageCount && (
<span title={`Page count: ${brew.pageCount}`}>
<i className='far fa-file' /> {brew.pageCount}
<span aria-label={`${brew.pageCount} pages`} title={`Page count: ${brew.pageCount}`}>
<span aria-hidden='true'><i className='far fa-file' /> {brew.pageCount}</span>
</span>
)}
<span
aria-label={`Last updated ${moment(brew.updatedAt).fromNow()}`}
title={dedent` Created: ${moment(brew.createdAt).local().format(dateFormatString)}
Last updated: ${moment(brew.updatedAt).local().format(dateFormatString)}`}
>
<i className='fas fa-sync-alt' /> {moment(brew.updatedAt).fromNow()}
<span aria-hidden='true'><i className='fas fa-sync-alt' /> {moment(brew.updatedAt).fromNow()}</span>
</span>
{renderStorageIcon()}
</div>
@@ -176,4 +176,4 @@ const BrewItem = ({
);
};
module.exports = BrewItem;
export default BrewItem;
@@ -1,3 +1,4 @@
@import '@sharedStyles/core.less';
.brewItem {
position : relative;
@@ -88,7 +89,7 @@
&::before { content : '\f518'; }
}
}
&:hover {
&:hover, &:focus-within {
.links { opacity : 1; }
}
&:nth-child(2n + 1) { margin-right : 0px; }
@@ -102,7 +103,7 @@
text-align : center;
background-color : fade(black, 60%);
opacity : 0;
a {
a, button {
.animate(opacity);
display : block;
margin : 8px 0px;
@@ -110,6 +111,7 @@
color : white;
text-decoration : unset;
opacity : 0.6;
width : 100%;
&:hover { opacity : 1; }
i { cursor : pointer; }
}
@@ -1,143 +1,128 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./listPage.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const moment = require('moment');
import './listPage.less';
import React, { useEffect, useState, useRef, useMemo } from 'react';
import moment from 'moment';
import _ from 'lodash';
const BrewItem = require('./brewItem/brewItem.jsx');
import BrewItem from './brewItem/brewItem.jsx';
const USERPAGE_KEY_PREFIX = 'HOMEBREWERY-LISTPAGE';
const USERPAGE_SORT_DIR = 'HB_listPage_sortDir';
const USERPAGE_SORT_TYPE = 'HB_listPage_sortType';
const USERPAGE_GROUP_VISIBILITY_PREFIX = 'HB_listPage_visibility_group';
const DEFAULT_SORT_TYPE = 'alpha';
const DEFAULT_SORT_DIR = 'asc';
const ListPage = createClass({
displayName : 'ListPage',
getDefaultProps : function() {
return {
brewCollection : [
{
title : '',
class : '',
brews : []
}
],
navItems : <></>,
reportError : null
const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navItems = <></>, reportError = null, query })=>{
const [filterString, setFilterString] = useState(query?.filter || '');
const [filterTags, setFilterTags] = useState([]);
const [sortType, setSortType] = useState(query?.sort || null);
const [sortDir, setSortDir] = useState(query?.dir || null);
const [groupVisibility, setGroupVisibility] = useState({});
const groupVisibilityRef = useRef(groupVisibility);
const sortTypeRef = useRef(sortType);
const sortDirRef = useRef(sortDir);
useEffect(()=>{
groupVisibilityRef.current = groupVisibility;
}, [groupVisibility]);
useEffect(()=>{
sortTypeRef.current = sortType;
}, [sortType]);
useEffect(()=>{
sortDirRef.current = sortDir;
}, [sortDir]);
useEffect(()=>{
window.onbeforeunload = saveToLocalStorage;
if(typeof window === 'undefined') return;
const newSortType = sortType ?? (localStorage.getItem(USERPAGE_SORT_TYPE) || DEFAULT_SORT_TYPE);
const newSortDir = sortDir ?? (localStorage.getItem(USERPAGE_SORT_DIR) || DEFAULT_SORT_DIR);
updateUrl(filterString, newSortType, newSortDir);
const namedBrewCollection = brewCollection.reduce((visibility, brewGroup)=>{
visibility[brewGroup.class] = (localStorage.getItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`) ?? 'true') == 'true';
return visibility;
}, {});
setGroupVisibility(namedBrewCollection);
setSortType(newSortType);
setSortDir(newSortDir);
return ()=>{
window.onbeforeunload = null;
};
},
getInitialState : function() {
// HIDE ALL GROUPS UNTIL LOADED
const brewCollection = this.props.brewCollection.map((brewGroup)=>{
brewGroup.visible = false;
return brewGroup;
});
}, []);
return {
filterString : this.props.query?.filter || '',
filterTags : [],
sortType : this.props.query?.sort || null,
sortDir : this.props.query?.dir || null,
query : this.props.query,
brewCollection : brewCollection
const saveToLocalStorage = ()=>{
brewCollection.forEach((brewGroup)=>{
localStorage.setItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`, `${groupVisibilityRef.current[brewGroup.class]}`);
});
localStorage.setItem(USERPAGE_SORT_TYPE, sortTypeRef.current);
localStorage.setItem(USERPAGE_SORT_DIR, sortDirRef.current);
};
},
componentDidMount : function() {
// SAVE TO LOCAL STORAGE WHEN LEAVING PAGE
window.onbeforeunload = this.saveToLocalStorage;
// LOAD FROM LOCAL STORAGE
if(typeof window !== 'undefined') {
const newSortType = (this.state.sortType ?? (localStorage.getItem(`${USERPAGE_KEY_PREFIX}-SORTTYPE`) || DEFAULT_SORT_TYPE));
const newSortDir = (this.state.sortDir ?? (localStorage.getItem(`${USERPAGE_KEY_PREFIX}-SORTDIR`) || DEFAULT_SORT_DIR));
this.updateUrl(this.state.filterString, newSortType, newSortDir);
const brewCollection = this.props.brewCollection.map((brewGroup)=>{
brewGroup.visible = (localStorage.getItem(`${USERPAGE_KEY_PREFIX}-VISIBILITY-${brewGroup.class}`) ?? 'true')=='true';
return brewGroup;
});
this.setState({
brewCollection : brewCollection,
sortType : newSortType,
sortDir : newSortDir
});
};
},
componentWillUnmount : function() {
window.onbeforeunload = function(){};
},
saveToLocalStorage : function() {
this.state.brewCollection.map((brewGroup)=>{
localStorage.setItem(`${USERPAGE_KEY_PREFIX}-VISIBILITY-${brewGroup.class}`, `${brewGroup.visible}`);
});
localStorage.setItem(`${USERPAGE_KEY_PREFIX}-SORTTYPE`, this.state.sortType);
localStorage.setItem(`${USERPAGE_KEY_PREFIX}-SORTDIR`, this.state.sortDir);
},
renderBrews : function(brews){
const renderBrews = (brews)=>{
if(!brews || !brews.length) return <div className='noBrews'>No Brews.</div>;
return _.map(brews, (brew, idx)=>{
return <BrewItem brew={brew} key={idx} reportError={this.props.reportError} updateListFilter={ (tag)=>{ this.updateUrl(this.state.filterString, this.state.sortType, this.state.sortDir, tag); }}/>;
});
},
return _.map(brews, (brew, idx)=>(
<BrewItem
brew={brew}
key={idx}
reportError={reportError}
updateListFilter={(tag)=>{
updateUrl(filterString, sortType, sortDir, tag);
}}
/>
));
};
sortBrewOrder : function(brew){
if(!brew.title){brew.title = 'No Title';}
const sortBrewOrder = (brew)=>{
const title = brew.title || 'No Title';
const mapping = {
'alpha' : _.deburr(brew.title.trim().toLowerCase()),
'alpha' : _.deburr(title.trim().toLowerCase()),
'created' : moment(brew.createdAt).format(),
'updated' : moment(brew.updatedAt).format(),
'views' : brew.views,
'latest' : moment(brew.lastViewed).format()
'latest' : moment(brew.lastViewed).format(),
};
return mapping[sortType];
};
return mapping[this.state.sortType];
},
handleSortOptionChange : function(event){
this.updateUrl(this.state.filterString, event.target.value, this.state.sortDir);
this.setState({
sortType : event.target.value
});
},
const handleSortOptionChange = (event)=>{
updateUrl(filterString, event.target.value, sortDir);
setSortType(event.target.value);
};
handleSortDirChange : function(event){
const newDir = this.state.sortDir == 'asc' ? 'desc' : 'asc';
const handleSortDirChange = (event)=>{
const newDir = sortDir == 'asc' ? 'desc' : 'asc';
this.updateUrl(this.state.filterString, this.state.sortType, newDir);
this.setState({
sortDir : newDir
});
},
updateUrl(filterString, sortType, newDir);
setSortDir(newDir);
};
renderSortOption : function(sortTitle, sortValue){
return <div className={`sort-option ${(this.state.sortType == sortValue ? 'active' : '')}`}>
<button
value={`${sortValue}`}
onClick={this.state.sortType == sortValue ? this.handleSortDirChange : this.handleSortOptionChange}
>
const renderSortOption = (sortTitle, sortValue)=>{
return (
<div className={`sort-option ${sortType == sortValue ? 'active' : ''}`}>
<button value={`${sortValue}`} onClick={sortType == sortValue ? handleSortDirChange : handleSortOptionChange}>
{`${sortTitle}`}
</button>
{this.state.sortType == sortValue &&
<i className={`sortDir fas ${this.state.sortDir == 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`}></i>
}
</div>;
},
{sortType == sortValue && <i className={`sortDir fas ${sortDir == 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`}></i>}
</div>
);
};
handleFilterTextChange : function(e){
this.setState({
filterString : e.target.value,
});
this.updateUrl(e.target.value, this.state.sortType, this.state.sortDir);
const handleFilterTextChange = (e)=>{
setFilterString(e.target.value);
updateUrl(e.target.value, sortType, sortDir);
return;
},
};
updateUrl : function(filterTerm, sortType, sortDir, filterTag=''){
const updateUrl = (filterTerm, sortType, sortDir, filterTag = '')=>{
const url = new URL(window.location.href);
const urlParams = new URLSearchParams(url.search);
@@ -146,135 +131,162 @@ const ListPage = createClass({
let filterTags = urlParams.getAll('tag');
if(filterTag != '') {
if(filterTags.findIndex((tag)=>{return tag.toLowerCase()==filterTag.toLowerCase();}) == -1){
if(
filterTags.findIndex((tag)=>{
return tag.toLowerCase() == filterTag.toLowerCase();
}) == -1
) {
filterTags.push(filterTag);
} else {
filterTags = filterTags.filter((tag)=>{ return tag.toLowerCase() != filterTag.toLowerCase(); });
filterTags = filterTags.filter((tag)=>{
return tag.toLowerCase() != filterTag.toLowerCase();
});
}
}
urlParams.delete('tag');
// Add tags to URL in the order they were clicked
filterTags.forEach((tag)=>{ urlParams.append('tag', tag); });
filterTags.forEach((tag)=>urlParams.append('tag', tag));
// Sort tags before updating state
filterTags.sort((a, b)=>{
return a.indexOf(':') - b.indexOf(':') != 0 ? a.indexOf(':') - b.indexOf(':') : a.toLowerCase().localeCompare(b.toLowerCase());
});
this.setState({
filterTags
});
setFilterTags(filterTags);
if(!filterTerm)
urlParams.delete('filter');
else
urlParams.set('filter', filterTerm);
if(!filterTerm) urlParams.delete('filter');
else urlParams.set('filter', filterTerm);
url.search = urlParams;
window.history.replaceState(null, null, url);
},
};
renderFilterOption : function(){
return <div className='filter-option'>
const renderFilterOption = ()=>{
return (
<div className='filter-option'>
<label>
<i className='fas fa-search'></i>
<input
type='search'
placeholder='filter title/description'
onChange={this.handleFilterTextChange}
value={this.state.filterString}
/>
<input type='search' placeholder='filter title/description/tags' onChange={handleFilterTextChange} value={filterString} />
</label>
</div>;
},
</div>
);
};
renderTagsOptions : function(){
if(this.state.filterTags?.length == 0) return;
return <div className='tags-container'>
{_.map(this.state.filterTags, (tag, idx)=>{
const renderTagsOptions = ()=>{
if(filterTags?.length == 0) return;
return (
<div className='tags-container'>
{_.map(filterTags, (tag, idx)=>{
const matches = tag.match(/^(?:([^:]+):)?([^:]+)$/);
return <span key={idx} className={matches[1]} onClick={()=>{ this.updateUrl(this.state.filterString, this.state.sortType, this.state.sortDir, tag); }}>{matches[2]}</span>;
return (
<span
key={idx}
className={matches[1]}
onClick={()=>{
updateUrl(filterString, sortType, sortDir, tag);
}}>
{matches[2]}
</span>
);
})}
</div>;
},
</div>
);
};
renderSortOptions : function(){
return <div className='sort-container'>
const renderSortOptions = ()=>{
return (
<div className='sort-container'>
<h6>Sort by :</h6>
{this.renderSortOption('Title', 'alpha')}
{this.renderSortOption('Created Date', 'created')}
{this.renderSortOption('Updated Date', 'updated')}
{this.renderSortOption('Views', 'views')}
{/* {this.renderSortOption('Latest', 'latest')} */}
{renderSortOption('Title', 'alpha')}
{renderSortOption('Created Date', 'created')}
{renderSortOption('Updated Date', 'updated')}
{renderSortOption('Views', 'views')}
{/* {renderSortOption('Latest', 'latest')} */}
{renderFilterOption()}
</div>
);
};
{this.renderFilterOption()}
</div>;
},
getSortedBrews : function(brews){
const testString = _.deburr(this.state.filterString).toLowerCase();
const getSortedBrews = (brews)=>{
const testString = _.deburr(filterString).toLowerCase();
brews = _.filter(brews, (brew)=>{
// Filter by user entered text
const brewStrings = _.deburr([
brew.title,
brew.description,
brew.tags].join('\n')
.toLowerCase());
const brewStrings = _.deburr([brew.title, brew.description, brew.tags].join('\n').toLowerCase());
const filterTextTest = brewStrings.includes(testString);
// Filter by user selected tags
let filterTagTest = true;
if(this.state.filterTags.length > 0){
filterTagTest = Array.isArray(brew.tags) && this.state.filterTags?.every((tag)=>{
return brew.tags.findIndex((brewTag)=>{
if(filterTags.length > 0) {
filterTagTest =
Array.isArray(brew.tags) &&
filterTags?.every((tag)=>{
return (
brew.tags.findIndex((brewTag)=>{
return brewTag.toLowerCase() == tag.toLowerCase();
}) >= 0;
}) >= 0
);
});
}
return filterTextTest && filterTagTest;
});
return _.orderBy(brews, (brew)=>{ return this.sortBrewOrder(brew); }, this.state.sortDir);
return _.orderBy(
brews,
(brew)=>{
return sortBrewOrder(brew);
},
sortDir,
);
};
toggleBrewCollectionState : function(brewGroupClass) {
this.setState((prevState)=>({
brewCollection : prevState.brewCollection.map(
(brewGroup)=>brewGroup.class === brewGroupClass ? { ...brewGroup, visible: !brewGroup.visible } : brewGroup
)
}));
},
const sortedBrewCollection = useMemo(()=>{
return brewCollection.map((brewGroup)=>({ ...brewGroup, brews: getSortedBrews(brewGroup.brews) }));
}, [brewCollection, filterString, filterTags, sortType, sortDir]);
renderBrewCollection : function(brewCollection){
if(brewCollection == []) return <div className='brewCollection'>
const toggleBrewCollectionState = (brewGroupClass)=>{
setGroupVisibility((prevVisibility)=>({ ...prevVisibility, [brewGroupClass]: !prevVisibility[brewGroupClass] }));
};
const renderBrewCollection = (brewCollection)=>{
if(brewCollection.length === 0)
return (
<div className='brewCollection'>
<h1>No Brews</h1>
</div>;
</div>
);
return _.map(brewCollection, (brewGroup, idx)=>{
return <div key={idx} className={`brewCollection ${brewGroup.class ?? ''}`}>
<h1 className={brewGroup.visible ? 'active' : 'inactive'} onClick={()=>{this.toggleBrewCollectionState(brewGroup.class);}}>{brewGroup.title || 'No Title'}</h1>
{brewGroup.visible ? this.renderBrews(this.getSortedBrews(brewGroup.brews)) : <></>}
</div>;
});
},
const sortedBrewGroup = sortedBrewCollection[idx];
const visible = groupVisibility[brewGroup.class];
render : function(){
return <div className='listPage sitePage'>
{/*<style>@layer V3_5ePHB, bundle;</style>*/}
return (
<div key={idx} className={`brewCollection ${brewGroup.class ?? ''}`}>
<h1
className={visible ? 'active' : 'inactive'}
onClick={()=>{
toggleBrewCollectionState(brewGroup.class);
}}>
{brewGroup.title || 'No Title'}
</h1>
{visible ? renderBrews(sortedBrewGroup.brews) : <></>}
</div>
);
});
};
return (
<div className='listPage sitePage'>
<link href='/themes/V3/Blank/style.css' type='text/css' rel='stylesheet' />
<link href='/themes/V3/5ePHB/style.css' type='text/css' rel='stylesheet' />
{this.props.navItems}
{this.renderSortOptions()}
{this.renderTagsOptions()}
{navItems}
{renderSortOptions()}
{renderTagsOptions()}
<div className='content V3'>
<div className='page'>
{this.renderBrewCollection(this.state.brewCollection)}
<div className='page'>{renderBrewCollection(brewCollection)}</div>
</div>
</div>
</div>;
}
});
);
};
module.exports = ListPage;
export default ListPage;
@@ -1,16 +1,17 @@
require('./uiPage.less');
const React = require('react');
const createClass = require('create-react-class');
import './uiPage.less';
import React from 'react';
import createReactClass from 'create-react-class';
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../../navbar/navbar.jsx');
const NewBrewItem = require('../../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../../navbar/help.navitem.jsx');
const RecentNavItem = require('../../../navbar/recent.navitem.jsx').both;
const Account = require('../../../navbar/account.navitem.jsx');
import Nav from '../../../navbar/nav.jsx';
import Navbar from '../../../navbar/navbar.jsx';
import NewBrewItem from '../../../navbar/newbrew.navitem.jsx';
import HelpNavItem from '../../../navbar/help.navitem.jsx';
import RecentNavItems from '../../../navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
import Account from '../../../navbar/account.navitem.jsx';
const UIPage = createClass({
const UIPage = createReactClass({
displayName : 'UIPage',
render : function(){
@@ -35,4 +36,4 @@ const UIPage = createClass({
}
});
module.exports = UIPage;
export default UIPage;
@@ -29,6 +29,7 @@
&::before {
margin-right : 5px;
font-family : 'Font Awesome 6 Free';
font-weight : 900;
content : '\f00c';
}
}
+302 -430
View File
@@ -1,529 +1,401 @@
/* eslint-disable max-lines */
require('./editPage.less');
const React = require('react');
const _ = require('lodash');
const createClass = require('create-react-class');
import {makePatches, applyPatches, stringifyPatches, parsePatches} from '@sanity/diff-match-patch';
import './editPage.less';
// Common imports
import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js';
import { hbfm } from 'hbmarkedwrapper';
import _ from 'lodash';
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.js'
import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx';
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
import AccountNavItem from '@navbar/account.navitem.jsx';
import ErrorNavItem from '@navbar/error-navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx';
import VaultNavItem from '@navbar/vault.navitem.jsx';
import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
// Page specific imports
import Headtags from '../../../../vitreum/headtags.js';
const Meta = Headtags.Meta;
import { md5 } from 'hash-wasm';
import { gzipSync, strToU8 } from 'fflate';
import { makePatches, stringifyPatches } from '@sanity/diff-match-patch';
import request from '../../utils/request-middleware.js';
const { Meta } = require('vitreum/headtags');
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const NewBrew = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx');
const PrintNavItem = require('../../navbar/print.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const Account = require('../../navbar/account.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const VaultNavItem = require('../../navbar/vault.navitem.jsx');
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
const LockNotification = require('./lockNotification/lockNotification.jsx');
import Markdown from 'naturalcrit/markdown.js';
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
import ShareNavItem from '@navbar/share.navitem.jsx';
import LockNotification from './lockNotification/lockNotification.jsx';
import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
const googleDriveIcon = require('../../googleDrive.svg');
import googleDriveIcon from '../../googleDrive.svg';
const SAVE_TIMEOUT = 10000;
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
const EditPage = createClass({
displayName : 'EditPage',
getDefaultProps : function() {
return {
brew : DEFAULT_BREW_LOAD
};
},
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
const SNIPKEY = 'HB_newPage_snippets';
const METAKEY = 'HB_newPage_meta';
getInitialState : function() {
return {
brew : this.props.brew,
isSaving : false,
unsavedChanges : false,
alertTrashedGoogleBrew : this.props.brew.trashed,
alertLoginToTransfer : false,
saveGoogle : this.props.brew.googleId ? true : false,
confirmGoogleTransfer : false,
error : null,
htmlErrors : Markdown.validate(this.props.brew.text),
url : '',
autoSave : true,
autoSaveWarning : false,
unsavedTime : new Date(),
currentEditorViewPageNum : 1,
currentEditorCursorPageNum : 1,
currentBrewRendererPageNum : 1,
displayLockMessage : this.props.brew.lock || false,
themeBundle : {}
};
},
const useLocalStorage = false;
const sandbox = false;
editor : React.createRef(null),
savedBrew : null,
componentDidMount : function(){
this.setState({
url : window.location.href
});
this.savedBrew = JSON.parse(JSON.stringify(this.props.brew)); //Deep copy
this.setState({ autoSave: JSON.parse(localStorage.getItem('AUTOSAVE_ON')) ?? true }, ()=>{
if(this.state.autoSave){
this.trySave();
} else {
this.setState({ autoSaveWarning: true });
}
});
window.onbeforeunload = ()=>{
if(this.state.isSaving || this.state.unsavedChanges){
return 'You have unsaved changes!';
}
const EditPage = (props)=>{
props = {
brew : DEFAULT_BREW_LOAD,
...props
};
this.setState((prevState)=>({
htmlErrors : Markdown.validate(prevState.brew.text)
}));
const [currentBrew, setCurrentBrew] = useState(props.brew);
const [isSaving, setIsSaving] = useState(false);
const [lastSavedTime, setLastSavedTime] = useState(new Date());
const [saveGoogle, setSaveGoogle] = useState(!!props.brew.googleId);
const [error, setError] = useState(null);
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text));
const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [alertTrashedGoogleBrew, setAlertTrashedGoogleBrew] = useState(props.brew.trashed);
const [alertNoGoogleToTransfer, setAlertNoGoogleToTransfer] = useState(false);
const [alertOwnershipToTransfer, setAlertOwnershipToTransfer] = useState(false);
const [confirmGoogleTransfer, setConfirmGoogleTransfer] = useState(false);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const saveTimeout = useRef(null);
const warnUnsavedTimeout = useRef(null);
const trySaveRef = useRef(null); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
document.addEventListener('keydown', this.handleControlKeys);
},
componentWillUnmount : function() {
window.onbeforeunload = function(){};
document.removeEventListener('keydown', this.handleControlKeys);
},
componentDidUpdate : function(){
const hasChange = this.hasChanges();
if(this.state.unsavedChanges != hasChange){
this.setState({
unsavedChanges : hasChange
const {
handleBrewChange
} = useCommonEditPageFunctions({
saveGoogle,
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
currentBrew,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
hbfm,
autoSaveEnabled,
setAutoSaveEnabled,
setWarnUnsavedChanges,
trySaveRef,
unsavedChangesRef,
setUnsavedChanges,
sandbox,
lastSavedBrew
});
}
},
handleControlKeys : function(e){
if(!(e.ctrlKey || e.metaKey)) return;
const S_KEY = 83;
const P_KEY = 80;
if(e.keyCode == S_KEY) this.trySave(true);
if(e.keyCode == P_KEY) printCurrentBrew();
if(e.keyCode == P_KEY || e.keyCode == S_KEY){
e.stopPropagation();
e.preventDefault();
}
},
useEffect(()=>{
trySaveRef.current = trySave;
unsavedChangesRef.current = unsavedChanges;
});
handleSplitMove : function(){
this.editor.current.update();
},
const handleSplitMove = ()=>{
editorRef.current?.update();
};
handleEditorViewPageChange : function(pageNumber){
this.setState({ currentEditorViewPageNum: pageNumber });
},
handleEditorCursorPageChange : function(pageNumber){
this.setState({ currentEditorCursorPageNum: pageNumber });
},
handleBrewRendererPageChange : function(pageNumber){
this.setState({ currentBrewRendererPageNum: pageNumber });
},
handleTextChange : function(text){
//If there are errors, run the validator on every change to give quick feedback
let htmlErrors = this.state.htmlErrors;
if(htmlErrors.length) htmlErrors = Markdown.validate(text);
this.setState((prevState)=>({
brew : { ...prevState.brew, text: text },
htmlErrors : htmlErrors,
}), ()=>{if(this.state.autoSave) this.trySave();});
},
handleSnipChange : function(snippet){
//If there are errors, run the validator on every change to give quick feedback
let htmlErrors = this.state.htmlErrors;
if(htmlErrors.length) htmlErrors = Markdown.validate(snippet);
this.setState((prevState)=>({
brew : { ...prevState.brew, snippets: snippet },
unsavedChanges : true,
htmlErrors : htmlErrors,
}), ()=>{if(this.state.autoSave) this.trySave();});
},
handleStyleChange : function(style){
this.setState((prevState)=>({
brew : { ...prevState.brew, style: style }
}), ()=>{if(this.state.autoSave) this.trySave();});
},
handleMetaChange : function(metadata, field=undefined){
if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed
fetchThemeBundle(this, metadata.renderer, metadata.theme);
this.setState((prevState)=>({
brew : {
...prevState.brew,
...metadata
}
}), ()=>{if(this.state.autoSave) this.trySave();});
},
hasChanges : function(){
return !_.isEqual(this.state.brew, this.savedBrew);
},
updateBrew : function(newData){
this.setState((prevState)=>({
brew : {
...prevState.brew,
const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({
...prevBrew,
style : newData.style,
text : newData.text,
snippets : newData.snippets
}
}));
},
trySave : function(immediate=false){
if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT);
if(this.state.isSaving)
return;
const resetWarnUnsavedTimer = ()=>{
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
clearTimeout(warnUnsavedTimeout.current);
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
};
if(immediate) {
this.debounceSave();
this.debounceSave.flush();
const handleGoogleClick = ()=>{
if(currentBrew.authors.length > 0 && global.account?.username !== currentBrew.authors[0]) {
setAlertOwnershipToTransfer(true);
return;
}
if(this.hasChanges())
this.debounceSave();
else
this.debounceSave.cancel();
},
handleGoogleClick : function(){
if(!global.account?.googleId) {
this.setState({
alertLoginToTransfer : true
});
setAlertNoGoogleToTransfer(true);
return;
}
this.setState((prevState)=>({
confirmGoogleTransfer : !prevState.confirmGoogleTransfer
}));
this.setState({
error : null
setConfirmGoogleTransfer((prev)=>!prev);
setError(null);
};
const closeAlerts = (e)=>{
e.stopPropagation(); //Only handle click once so alert doesn't reopen
setAlertTrashedGoogleBrew(false);
setAlertNoGoogleToTransfer(false);
setConfirmGoogleTransfer(false);
setAlertOwnershipToTransfer(false);
};
const toggleGoogleStorage = (e)=>{
closeAlerts(e);
const newSaveGoogle = !saveGoogle;
setSaveGoogle((prev)=>!prev);
setError(null);
trySave(true, true, newSaveGoogle);
};
const trySave = (immediate = false, hasChanges = true, saveToGoogle = false)=>{
clearTimeout(saveTimeout.current);
if(isSaving) return;
if(!hasChanges && !immediate) return;
const newTimeout = immediate ? 0 : SAVE_TIMEOUT;
saveTimeout.current = setTimeout(async ()=>{
setIsSaving(true);
setError(null);
await save(currentBrew, saveToGoogle)
.catch((err)=>{
setError(err);
});
},
setIsSaving(false);
setLastSavedTime(new Date());
if(!autoSaveEnabled) resetWarnUnsavedTimer();
}, newTimeout);
};
closeAlerts : function(event){
event.stopPropagation(); //Only handle click once so alert doesn't reopen
this.setState({
alertTrashedGoogleBrew : false,
alertLoginToTransfer : false,
confirmGoogleTransfer : false
});
},
const save = async (brew, saveToGoogle)=>{
setHTMLErrors(hbfm.validate(brew.text));
toggleGoogleStorage : function(){
this.setState((prevState)=>({
saveGoogle : !prevState.saveGoogle,
error : null
}), ()=>this.trySave(true));
},
save : async function(){
if(this.debounceSave && this.debounceSave.cancel) this.debounceSave.cancel();
const brewState = this.state.brew; // freeze the current state
const preSaveSnapshot = { ...brewState };
this.setState((prevState)=>({
isSaving : true,
error : null,
htmlErrors : Markdown.validate(prevState.brew.text)
}));
await updateHistory(this.state.brew).catch(console.error);
await updateHistory(brew).catch(console.error);
await versionHistoryGarbageCollection().catch(console.error);
//Prepare content to send to server
const brew = { ...brewState };
brew.text = brew.text.normalize('NFC');
this.savedBrew.text = this.savedBrew.text.normalize('NFC');
brew.pageCount = ((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
brew.patches = stringifyPatches(makePatches(this.savedBrew.text, brew.text));
brew.hash = await md5(this.savedBrew.text);
//brew.text = undefined; - Temporary parallel path
brew.textBin = undefined;
const brewToSave = {
...brew,
text : brew.text.normalize('NFC'),
pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm)) || []).length + 1,
patches : stringifyPatches(makePatches(encodeURI(lastSavedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))),
hash : await md5(lastSavedBrew.current.text.normalize('NFC')),
textBin : undefined,
version : lastSavedBrew.current.version
};
const compressedBrew = gzipSync(strToU8(JSON.stringify(brew)));
const compressedBrew = gzipSync(strToU8(JSON.stringify(brewToSave)));
const transfer = saveToGoogle === _.isNil(brew.googleId);
const params = transfer ? `?${saveToGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : '';
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
const params = `${transfer ? `?${this.state.saveGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : ''}`;
const res = await request
.put(`/api/update/${brew.editId}${params}`)
.put(`/api/update/${brewToSave.editId}${params}`)
.set('Content-Encoding', 'gzip')
.set('Content-Type', 'application/json')
.send(compressedBrew)
.catch((err)=>{
console.log('Error Updating Local Brew');
this.setState({ error: err });
console.error('Error Updating Local Brew');
setError(err);
});
if(!res) return;
this.savedBrew = {
...preSaveSnapshot,
googleId : res.body.googleId ? res.body.googleId : null,
const updatedFields = {
googleId : res.body.googleId ?? null,
editId : res.body.editId,
shareId : res.body.shareId,
version : res.body.version
};
this.setState((prevState) => ({
brew: {
...prevState.brew,
googleId : res.body.googleId ? res.body.googleId : null,
editId : res.body.editId,
shareId : res.body.shareId,
version : res.body.version
},
isSaving : false,
unsavedTime : new Date()
}), ()=>{
this.setState({ unsavedChanges : this.hasChanges() });
});
lastSavedBrew.current = {
...brew,
...updatedFields
};
history.replaceState(null, null, `/edit/${this.savedBrew.editId}`);
},
setCurrentBrew((prevBrew)=>({
...prevBrew,
...updatedFields
}));
renderGoogleDriveIcon : function(){
return <Nav.item className='googleDriveStorage' onClick={this.handleGoogleClick}>
<img src={googleDriveIcon} className={this.state.saveGoogle ? '' : 'inactive'} alt='Google Drive icon'/>
history.replaceState(null, null, `/edit/${res.body.editId}`);
};
{this.state.confirmGoogleTransfer &&
<div className='errorContainer' onClick={this.closeAlerts}>
{ this.state.saveGoogle
? `Would you like to transfer this brew from your Google Drive storage back to the Homebrewery?`
: `Would you like to transfer this brew from the Homebrewery to your personal Google Drive storage?`
}
<br />
<div className='confirm' onClick={this.toggleGoogleStorage}>
Yes
</div>
<div className='deny'>
No
</div>
</div>
}
const renderGoogleDriveIcon = ()=>(
<Nav.item className='googleDriveStorage' onClick={handleGoogleClick}>
<img src={googleDriveIcon} className={saveGoogle ? '' : 'inactive'} alt='Google Drive icon' />
{this.state.alertLoginToTransfer &&
<div className='errorContainer' onClick={this.closeAlerts}>
You must be signed in to a Google account to transfer
between the homebrewery and Google Drive!
<a target='_blank' rel='noopener noreferrer'
href={`https://www.naturalcrit.com/login?redirect=${this.state.url}`}>
<div className='confirm'>
Sign In
{alertOwnershipToTransfer && (
<div className='errorContainer'>
You must be the Owner to transfer between the Homebrewery and Google Drive!
The owner of this file is {currentBrew.authors[0]}.
<br></br>
<div className='confirm' onClick={closeAlerts}> Okay </div>
</div>
)}
{alertNoGoogleToTransfer && (
<div className='errorContainer'>
You must be signed in to a Google account to transfer between the Homebrewery and Google Drive!
<a target='_blank' rel='noopener noreferrer' href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
<div className='confirm' onClick={closeAlerts}> Sign In </div>
</a>
<div className='deny'>
Not Now
<div className='deny' onClick={closeAlerts}> Not Now </div>
</div>
</div>
}
)}
{this.state.alertTrashedGoogleBrew &&
<div className='errorContainer' onClick={this.closeAlerts}>
This brew is currently in your Trash folder on Google Drive!<br />If you want to keep it, make sure to move it before it is deleted permanently!<br />
<div className='confirm'>
OK
{alertTrashedGoogleBrew && (
<div className='errorContainer'>
This brew is currently in your Trash folder on Google Drive!<br />
If you want to keep it, make sure to move it before it is deleted permanently!<br />
<div className='confirm' onClick={toggleGoogleStorage}> Save my brew </div>
</div>
)}
{confirmGoogleTransfer && (
<div className='errorContainer'>
{saveGoogle
? 'Would you like to transfer this brew from your Google Drive storage back to the Homebrewery?'
: 'Would you like to transfer this brew from the Homebrewery to your personal Google Drive storage?'}
<br />
<div className='confirm' onClick={toggleGoogleStorage}> Yes </div>
<div className='deny' onClick={closeAlerts}> No </div>
</div>
}
</Nav.item>;
},
renderSaveButton : function(){
)}
</Nav.item>
);
const renderSaveButton = ()=>{
// #1 - Currently saving, show SAVING
if(this.state.isSaving){
if(isSaving)
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
}
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
if(this.state.unsavedChanges && this.state.autoSaveWarning){
this.setAutosaveWarning();
const elapsedTime = Math.round((new Date() - this.state.unsavedTime) / 1000 / 60);
const text = elapsedTime == 0 ? 'Autosave is OFF.' : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
if(unsavedChanges && warnUnsavedChanges) {
resetWarnUnsavedTimer();
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
const text = elapsedTime === 0
? `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}.`
: `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}, and you haven't saved for ${elapsedTime} minutes.`;
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
Reminder...
<div className='errorContainer'>
{text}
</div>
<div className='errorContainer'>{text}</div>
</Nav.item>;
}
// #3 - Unsaved changes exist, click to save, show SAVE NOW
// Use trySave(true) instead of save() to use debounced save function
if(this.state.unsavedChanges){
return <Nav.item className='save' onClick={()=>this.trySave(true)} color='blue' icon='fas fa-save'>Save Now</Nav.item>;
}
if(unsavedChanges)
return <Nav.item className='save' onClick={()=>trySave(true, true, saveGoogle)} color='blue' icon='fas fa-save'>save now</Nav.item>;
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(this.state.autoSave){
return <Nav.item className='save saved'>auto-saved.</Nav.item>;
}
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
// #5 - Sandbox with no unsaved changes, and has never been saved, hide the button
if(sandbox)
return <Nav.item className='save sandbox' disabled={true}>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved.</Nav.item>;
},
return <Nav.item className='save saved'>saved</Nav.item>;
};
handleAutoSave : function(){
if(this.warningTimer) clearTimeout(this.warningTimer);
this.setState((prevState)=>({
autoSave : !prevState.autoSave,
autoSaveWarning : prevState.autoSave
}), ()=>{
localStorage.setItem('AUTOSAVE_ON', JSON.stringify(this.state.autoSave));
});
},
const toggleAutoSave = ()=>{
clearTimeout(warnUnsavedTimeout.current);
clearTimeout(saveTimeout.current);
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled));
setAutoSaveEnabled(!autoSaveEnabled);
setWarnUnsavedChanges(autoSaveEnabled);
};
setAutosaveWarning : function(){
setTimeout(()=>this.setState({ autoSaveWarning: false }), 4000); // 4 seconds to display
this.warningTimer = setTimeout(()=>{this.setState({ autoSaveWarning: true });}, 900000); // 15 minutes between warnings
this.warningTimer;
},
const renderAutoSaveButton = ()=>(
<Nav.item onClick={toggleAutoSave}>
Autosave <i className={autoSaveEnabled ? 'fas fa-power-off active' : 'fas fa-power-off'}></i>
</Nav.item>
);
errorReported : function(error) {
this.setState({
error
});
},
renderAutoSaveButton : function(){
return <Nav.item onClick={this.handleAutoSave}>
Autosave <i className={this.state.autoSave ? 'fas fa-power-off active' : 'fas fa-power-off'}></i>
</Nav.item>;
},
processShareId : function() {
return this.state.brew.googleId && !this.state.brew.stubbed ?
this.state.brew.googleId + this.state.brew.shareId :
this.state.brew.shareId;
},
getRedditLink : function(){
const shareLink = this.processShareId();
const systems = this.props.brew.systems.length > 0 ? ` [${this.props.brew.systems.join(' - ')}]` : '';
const title = `${this.props.brew.title} ${systems}`;
const text = `Hey guys! I've been working on this homebrew. I'd love your feedback. Check it out.
**[Homebrewery Link](${global.config.baseUrl}/share/${shareLink})**`;
return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title.toWellFormed())}&text=${encodeURIComponent(text)}`;
},
renderNavbar : function(){
const shareLink = this.processShareId();
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
const renderNavbar = ()=>{
return <Navbar>
<Nav.section>
<Nav.item className='brewTitle'>{this.state.brew.title}</Nav.item>
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item>
</Nav.section>
<Nav.section>
{this.renderGoogleDriveIcon()}
{this.state.error ?
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
<Nav.dropdown className='save-menu'>
{this.renderSaveButton()}
{this.renderAutoSaveButton()}
</Nav.dropdown>
}
<NewBrew />
<HelpNavItem/>
<Nav.dropdown>
<Nav.item color='teal' icon='fas fa-share-alt'>
share
</Nav.item>
<Nav.item color='blue' href={`/share/${shareLink}`}>
view
</Nav.item>
<Nav.item color='blue' onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${shareLink}`);}}>
copy url
</Nav.item>
<Nav.item color='blue' href={this.getRedditLink()} newTab={true} rel='noopener noreferrer'>
post to reddit
</Nav.item>
</Nav.dropdown>
{renderGoogleDriveIcon()}
{error
? <ErrorNavItem error={error} clearError={clearError} />
: <Nav.dropdown className='save-menu'>
{renderSaveButton()}
{renderAutoSaveButton()}
</Nav.dropdown>}
<NewBrewItem />
<PrintNavItem />
<HelpNavItem />
<VaultNavItem />
<RecentNavItem brew={this.state.brew} storageKey='edit' />
<Account />
<ShareNavItem brew={currentBrew} currentPage={currentBrewRendererPageNum} />
<RecentNavItem brew={currentBrew} storageKey='edit' />
<AccountNavItem/>
</Nav.section>
</Navbar>;
},
};
render : function(){
return <div className='editPage sitePage'>
return (
<div className='editPage sitePage'>
<Meta name='robots' content='noindex, nofollow' />
{this.renderNavbar()}
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} reviewRequested={this.props.brew.lock.reviewRequested} />}
{renderNavbar()}
{currentBrew.lock && <LockNotification shareId={currentBrew.shareId} message={currentBrew.lock.editMessage} reviewRequested={currentBrew.lock.reviewRequested}/>}
<div className='content'>
<SplitPane onDragFinish={this.handleSplitMove}>
<SplitPane onDragFinish={handleSplitMove}>
<Editor
ref={this.editor}
brew={this.state.brew}
onTextChange={this.handleTextChange}
onStyleChange={this.handleStyleChange}
onSnipChange={this.handleSnipChange}
onMetaChange={this.handleMetaChange}
reportError={this.errorReported}
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
themeBundle={this.state.themeBundle}
updateBrew={this.updateBrew}
onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
ref={editorRef}
brew={currentBrew}
onBrewChange={handleBrewChange}
reportError={setError}
renderer={currentBrew.renderer}
userThemes={props.userThemes}
themeBundle={themeBundle}
updateBrew={updateBrew}
onCursorPageChange={setCurrentEditorCursorPageNum}
onViewPageChange={setCurrentEditorViewPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors}
lang={this.state.brew.lang}
onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
text={currentBrew.text}
style={currentBrew.style}
renderer={currentBrew.renderer}
theme={currentBrew.theme}
themeBundle={themeBundle}
errors={HTMLErrors}
lang={currentBrew.lang}
onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
allowPrint={true}
/>
</SplitPane>
</div>
</div>;
}
});
</div>
);
};
module.exports = EditPage;
export default EditPage;
@@ -1,7 +1,7 @@
import './lockNotification.less';
import * as React from 'react';
import request from '../../../utils/request-middleware.js';
import Dialog from '../../../../components/dialog.jsx';
import Dialog from '@components/dialog.jsx';
function LockNotification(props) {
props = {
@@ -40,4 +40,4 @@ function LockNotification(props) {
</Dialog>;
};
module.exports = LockNotification;
export default LockNotification;
@@ -1,8 +1,8 @@
require('./errorPage.less');
const React = require('react');
const UIPage = require('../basePages/uiPage/uiPage.jsx');
import Markdown from '../../../../shared/naturalcrit/markdown.js';
const ErrorIndex = require('./errors/errorIndex.js');
import './errorPage.less';
import React from 'react';
import UIPage from '../basePages/uiPage/uiPage.jsx';
import { hbfm } from 'hbmarkedwrapper';
import ErrorIndex from './errors/errorIndex.js';
const ErrorPage = ({ brew })=>{
// Retrieving the error text based on the brew's error code from ErrorIndex
@@ -16,10 +16,10 @@ const ErrorPage = ({ brew })=>{
<h4>{brew?.text || 'No error text'}</h4>
</div>
<hr />
<div dangerouslySetInnerHTML={{ __html: Markdown.render(errorText) }} />
<div dangerouslySetInnerHTML={{ __html: hbfm.render(errorText) }} />
</div>
</UIPage>
);
};
module.exports = ErrorPage;
export default ErrorPage;
@@ -1,7 +1,6 @@
.homebrew {
.uiPage.sitePage {
.uiPage.sitePage:has(.errorTitle) {
.errorTitle {
//background-color: @orange;
color : #D02727;
text-align : center;
}
@@ -1,4 +1,4 @@
const dedent = require('dedent-tabs').default;
import dedent from 'dedent';
const loginUrl = 'https://www.naturalcrit.com/login';
@@ -96,7 +96,7 @@ const errorIndex = (props)=>{
**Brew Title:** ${escape(props.brew.brewTitle) || 'Unable to show title'}
**Current Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${author})`;}).join(', ') || 'Unable to list authors'}
**Current Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${encodeURIComponent(author)})`;}).join(', ') || 'Unable to list authors'}
[Click here to be redirected to the brew's share page.](/share/${props.brew.shareId})`,
@@ -111,7 +111,7 @@ const errorIndex = (props)=>{
**Brew Title:** ${escape(props.brew.brewTitle) || 'Unable to show title'}
**Current Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${author})`;}).join(', ') || 'Unable to list authors'}
**Current Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${encodeURIComponent(author)})`;}).join(', ') || 'Unable to list authors'}
[Click here to be redirected to the brew's share page.](/share/${props.brew.shareId})`,
@@ -196,6 +196,12 @@ const errorIndex = (props)=>{
**Brew ID:** ${props.brew.brewId}`,
// Database Connection Lost
'13' : dedent`
## Database connection has been lost.
The server could not communicate with the database.`,
//account page when account is not defined
'50' : dedent`
## You are not signed in
@@ -216,7 +222,7 @@ const errorIndex = (props)=>{
**Brew Title:** ${escape(props.brew.brewTitle)}
**Brew Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${author})`;}).join(', ') || 'Unable to list authors'}`,
**Brew Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${encodeURIComponent(author)})`;}).join(', ') || 'Unable to list authors'}`,
// ####### Admin page error #######
'52' : dedent`
@@ -262,4 +268,4 @@ const errorIndex = (props)=>{
};
};
module.exports = errorIndex;
export default errorIndex;
+176 -96
View File
@@ -1,141 +1,221 @@
require('./homePage.less');
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
import './homePage.less';
// Common imports
import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js';
const { Meta } = require('vitreum/headtags');
import { hbfm } from 'hbmarkedwrapper';
import _ from 'lodash';
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const NewBrewItem = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx');
const VaultNavItem = require('../../navbar/vault.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const AccountNavItem = require('../../navbar/account.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const { fetchThemeBundle } = require('../../../../shared/helpers.js');
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.js'
const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js');
import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
const HomePage = createClass({
displayName : 'HomePage',
getDefaultProps : function() {
return {
import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx';
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
import AccountNavItem from '@navbar/account.navitem.jsx';
import ErrorNavItem from '@navbar/error-navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx';
import VaultNavItem from '@navbar/vault.navitem.jsx';
import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
// Page specific imports
import Headtags from '@vitreum/headtags.js';
const Meta = Headtags.Meta;
const SAVE_TIMEOUT = 10000;
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
const SNIPKEY = 'HB_newPage_snippets';
const METAKEY = 'HB_newPage_meta';
const useLocalStorage = false;
const sandbox = true;
const HomePage =(props)=>{
props = {
brew : DEFAULT_BREW,
ver : '0.0.0'
...props
};
},
getInitialState : function() {
return {
brew : this.props.brew,
welcomeText : this.props.brew.text,
error : undefined,
currentEditorViewPageNum : 1,
currentEditorCursorPageNum : 1,
currentBrewRendererPageNum : 1,
themeBundle : {}
};
},
editor : React.createRef(null),
const [currentBrew, setCurrentBrew] = useState(props.brew);
const [error, setError] = useState(undefined);
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text));
const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [lastSavedTime, setLastSavedTime] = useState(new Date());
const [autoSaveEnabled, setAutoSaveEnabled] = useState(false);
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
componentDidMount : function() {
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
},
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const warnUnsavedTimeout = useRef(null);
const trySaveRef = useRef(null); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
const unsavedChangesRef = useRef(unsavedChanges);
handleSave : function(){
const {
handleBrewChange
} = useCommonEditPageFunctions({
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
currentBrew,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
hbfm,
autoSaveEnabled,
setAutoSaveEnabled,
setWarnUnsavedChanges,
trySaveRef,
unsavedChangesRef,
setUnsavedChanges,
sandbox,
lastSavedBrew
});
useEffect(()=>{
unsavedChangesRef.current = unsavedChanges;
}, [unsavedChanges]);
const save = ()=>{
request.post('/api')
.send(this.state.brew)
.send(currentBrew)
.end((err, res)=>{
if(err) {
this.setState({ error: err });
setError(err);
return;
}
const brew = res.body;
window.location = `/edit/${brew.editId}`;
const saved = res.body;
window.location = `/edit/${saved.editId}`;
});
},
handleSplitMove : function(){
this.editor.current.update();
},
};
handleEditorViewPageChange : function(pageNumber){
this.setState({ currentEditorViewPageNum: pageNumber });
},
const handleSplitMove = ()=>{
editorRef.current.update();
};
handleEditorCursorPageChange : function(pageNumber){
this.setState({ currentEditorCursorPageNum: pageNumber });
},
const resetWarnUnsavedTimer = ()=>{
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
clearTimeout(warnUnsavedTimeout.current);
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
};
handleBrewRendererPageChange : function(pageNumber){
this.setState({ currentBrewRendererPageNum: pageNumber });
},
const renderSaveButton = ()=>{
// #1 - Currently saving, show SAVING
if(isSaving)
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
handleTextChange : function(text){
this.setState((prevState)=>({
brew : { ...prevState.brew, text: text },
}));
},
renderNavbar : function(){
return <Navbar ver={this.props.ver}>
<Nav.section>
{this.state.error ?
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
null
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
if(unsavedChanges && warnUnsavedChanges) {
resetWarnUnsavedTimer();
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
const text = elapsedTime === 0
? `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}.`
: `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}, and you haven't saved for ${elapsedTime} minutes.`;
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
Reminder...
<div className='errorContainer'>{text}</div>
</Nav.item>;
}
// #3 - Unsaved changes exist, click to save, show SAVE NOW
if(unsavedChanges)
return <Nav.item className='save' onClick={save} color='blue' icon='fas fa-save'>save now</Nav.item>;
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
// #5 - Sandbox with no unsaved changes, and has never been saved, hide the button
if(sandbox)
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>;
};
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
const renderNavbar = ()=>{
return <Navbar ver={props.ver}>
<Nav.section>
{error
? <ErrorNavItem error={error} clearError={clearError} />
: renderSaveButton()}
<NewBrewItem />
<PrintNavItem />
<HelpNavItem />
<VaultNavItem />
<RecentNavItem />
<AccountNavItem />
</Nav.section>
</Navbar>;
},
};
render : function(){
return <div className='homePage sitePage'>
return (
<div className='homePage sitePage'>
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
{this.renderNavbar()}
{renderNavbar()}
<div className='content'>
<SplitPane onDragFinish={this.handleSplitMove}>
<SplitPane onDragFinish={handleSplitMove}>
<Editor
ref={this.editor}
brew={this.state.brew}
onTextChange={this.handleTextChange}
renderer={this.state.brew.renderer}
ref={editorRef}
brew={currentBrew}
onBrewChange={handleBrewChange}
renderer={currentBrew.renderer}
showEditButtons={false}
themeBundle={this.state.themeBundle}
onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
themeBundle={themeBundle}
onCursorPageChange={setCurrentEditorCursorPageNum}
onViewPageChange={setCurrentEditorViewPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
themeBundle={this.state.themeBundle}
text={currentBrew.text}
style={currentBrew.style}
renderer={currentBrew.renderer}
onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
themeBundle={themeBundle}
/>
</SplitPane>
</div>
<div className={cx('floatingSaveButton', { show: this.state.welcomeText != this.state.brew.text })} onClick={this.handleSave}>
<div className={`floatingSaveButton${unsavedChanges ? ' show' : ''}`} onClick={save}>
Save current <i className='fas fa-save' />
</div>
<a href='/new' className='floatingNewButton'>
Create your own <i className='fas fa-magic' />
</a>
</div>;
}
});
</div>
);
};
module.exports = HomePage;
export default HomePage;
@@ -1,3 +1,5 @@
@import '@sharedStyles/core.less';
.homePage {
position : relative;
a.floatingNewButton {
@@ -35,6 +37,14 @@
.navItem.save {
background-color : @orange;
transition:all 0.2s;
&:hover { background-color : @green; }
&.neverSaved {
translate:-100%;
opacity: 0;
background-color :#333;
cursor:auto;
}
}
}
@@ -36,7 +36,7 @@ After clicking the "Print" item in the navbar a new page will open and a print d
If you want to save ink or have a monochrome printer, add the **PRINT → {{fas,fa-tint}} Ink Friendly** snippet to your brew!
}}
![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px}
![homebrew mug](https://homebrewery.naturalcrit.com/assets/homebrewerymug.png) {position:absolute,bottom:20px,left:130px,width:220px}
{{artist,bottom:160px,left:100px
##### Homebrew Mug
@@ -77,16 +77,16 @@ If you wish to sell or in some way gain profit for what's created on this site,
If you'd like to credit us in your brew, we'd be flattered! Just reference that you made it with The Homebrewery.
### More Homebrew Resources
[![Discord](/assets/discordOfManyThings.svg){width:50px,float:right,padding-left:10px}](https://discord.gg/by3deKx)
[![Discord](https://homebrewery.naturalcrit.com/assets/discordOfManyThings.svg){width:50px,float:right,padding-left:10px}](https://discord.gg/by3deKx)
If you are looking for more 5e Homebrew resources check out [r/UnearthedArcana](https://www.reddit.com/r/UnearthedArcana/) and their list of useful resources [here](https://www.reddit.com/r/UnearthedArcana/wiki/resources). The [Discord Of Many Things](https://discord.gg/by3deKx) is another great resource to connect with fellow homebrewers for help and feedback.
{{position:absolute;top:20px;right:20px;width:auto
[![Discord](/assets/discord.png){height:30px}](https://discord.gg/by3deKx)
[![Github](/assets/github.png){height:30px}](https://github.com/naturalcrit/homebrewery)
[![Patreon](/assets/patreon.png){height:30px}](https://patreon.com/NaturalCrit)
[![Reddit](/assets/reddit.png){height:30px}](https://www.reddit.com/r/homebrewery/)
[![Discord](https://homebrewery.naturalcrit.com/assets/discord.png){height:30px}](https://discord.gg/by3deKx)
[![Github](https://homebrewery.naturalcrit.com/assets/github.png){height:30px}](https://github.com/naturalcrit/homebrewery)
[![Patreon](https://homebrewery.naturalcrit.com/assets/patreon.png){height:30px}](https://patreon.com/NaturalCrit)
[![Reddit](https://homebrewery.naturalcrit.com/assets/reddit.png){height:30px}](https://www.reddit.com/r/homebrewery/)
}}
\page
@@ -162,7 +162,7 @@ Images must be hosted online somewhere, like [Imgur](https://www.imgur.com). You
Using *Curly Injection* you can assign an id, classes, or inline CSS properties to the Markdown image syntax.
![alt-text](https://s-media-cache-ak0.pinimg.com/736x/4a/81/79/4a8179462cfdf39054a418efd4cb743e.jpg) {width:100px,border:"2px solid",border-radius:10px}
![alt-text](https://homebrewery.naturalcrit.com/assets/catwarrior.jpg) {width:100px,border:"2px solid",border-radius:10px}
\* *When using Imgur-hosted images, use the "direct link", which can be found when you click into your image in the Imgur interface.*
+204 -207
View File
@@ -1,275 +1,272 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./newPage.less');
const React = require('react');
const createClass = require('create-react-class');
/* eslint-disable max-lines */
import './newPage.less';
// Common imports
import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js';
import { hbfm } from 'hbmarkedwrapper';
import _ from 'lodash';
import Markdown from 'naturalcrit/markdown.js';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js';
const Nav = require('naturalcrit/nav/nav.jsx');
const PrintNavItem = require('../../navbar/print.navitem.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const AccountNavItem = require('../../navbar/account.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const HelpNavItem = require('../../navbar/help.navitem.jsx');
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.js'
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx';
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
import AccountNavItem from '@navbar/account.navitem.jsx';
import ErrorNavItem from '@navbar/error-navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx';
import VaultNavItem from '@navbar/vault.navitem.jsx';
import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style';
const METAKEY = 'homebrewery-new-meta';
let SAVEKEY;
// Page specific imports
const SAVE_TIMEOUT = 10000;
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
const SNIPKEY = 'HB_newPage_snippets';
const METAKEY = 'HB_newPage_meta';
const NewPage = createClass({
displayName : 'NewPage',
getDefaultProps : function() {
return {
brew : DEFAULT_BREW
const SAVEKEYPREFIX = 'HB_editor_defaultSave_';
const useLocalStorage = true;
const sandbox = true;
const NewPage = (props)=>{
props = {
brew : DEFAULT_BREW,
...props
};
},
getInitialState : function() {
const brew = this.props.brew;
const [currentBrew, setCurrentBrew] = useState(props.brew);
const [isSaving, setIsSaving] = useState(false);
const [lastSavedTime, setLastSavedTime] = useState(new Date());
const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false);
const [error, setError] = useState(null);
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text));
const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(false);
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
return {
brew : brew,
isSaving : false,
saveGoogle : (global.account && global.account.googleId ? true : false),
error : null,
htmlErrors : Markdown.validate(brew.text),
currentEditorViewPageNum : 1,
currentEditorCursorPageNum : 1,
currentBrewRendererPageNum : 1,
themeBundle : {}
};
},
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
// const saveTimeout = useRef(null);
const warnUnsavedTimeout = useRef(null);
const trySaveRef = useRef(null); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
editor : React.createRef(null),
useEffect(()=>{
loadBrew();
}, []);
componentDidMount : function() {
document.addEventListener('keydown', this.handleControlKeys);
const {
handleBrewChange
} = useCommonEditPageFunctions({
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
currentBrew,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
hbfm,
autoSaveEnabled,
setAutoSaveEnabled,
setWarnUnsavedChanges,
trySaveRef,
unsavedChangesRef,
setUnsavedChanges,
sandbox,
lastSavedBrew
});
const brew = this.state.brew;
if(!this.props.brew.shareId && typeof window !== 'undefined') { //Load from localStorage if in client browser
const loadBrew = ()=>{
const brew = { ...currentBrew };
if(!brew.shareId && typeof window !== 'undefined') { //Load from localStorage if in client browser
const brewStorage = localStorage.getItem(BREWKEY);
const styleStorage = localStorage.getItem(STYLEKEY);
const metaStorage = JSON.parse(localStorage.getItem(METAKEY));
brew.text = brewStorage ?? brew.text;
brew.style = styleStorage ?? brew.style;
// brew.title = metaStorage?.title || this.state.brew.title;
// brew.description = metaStorage?.description || this.state.brew.description;
brew.renderer = metaStorage?.renderer ?? brew.renderer;
brew.theme = metaStorage?.theme ?? brew.theme;
brew.lang = metaStorage?.lang ?? brew.lang;
}
SAVEKEY = `HOMEBREWERY-DEFAULT-SAVE-LOCATION-${global.account?.username || ''}`;
const SAVEKEY = `${SAVEKEYPREFIX}${global.account?.username}`;
const saveStorage = localStorage.getItem(SAVEKEY) || 'HOMEBREWERY';
this.setState({
brew : brew,
saveGoogle : (saveStorage == 'GOOGLE-DRIVE' && this.state.saveGoogle)
});
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
setCurrentBrew(brew);
lastSavedBrew.current = brew;
setSaveGoogle(saveStorage == 'GOOGLE-DRIVE' && saveGoogle);
localStorage.setItem(BREWKEY, brew.text);
if(brew.style)
localStorage.setItem(STYLEKEY, brew.style);
localStorage.setItem(METAKEY, JSON.stringify({ 'renderer': brew.renderer, 'theme': brew.theme, 'lang': brew.lang }));
if(window.location.pathname != '/new') {
localStorage.setItem(METAKEY, JSON.stringify({ renderer: brew.renderer, theme: brew.theme, lang: brew.lang }));
if(window.location.pathname !== '/new')
window.history.replaceState({}, window.location.title, '/new/');
}
},
componentWillUnmount : function() {
document.removeEventListener('keydown', this.handleControlKeys);
},
};
handleControlKeys : function(e){
if(!(e.ctrlKey || e.metaKey)) return;
const S_KEY = 83;
const P_KEY = 80;
if(e.keyCode == S_KEY) this.save();
if(e.keyCode == P_KEY) printCurrentBrew();
if(e.keyCode == P_KEY || e.keyCode == S_KEY){
e.stopPropagation();
e.preventDefault();
}
},
handleSplitMove : function(){
this.editor.current.update();
},
handleEditorViewPageChange : function(pageNumber){
this.setState({ currentEditorViewPageNum: pageNumber });
},
handleEditorCursorPageChange : function(pageNumber){
this.setState({ currentEditorCursorPageNum: pageNumber });
},
handleBrewRendererPageChange : function(pageNumber){
this.setState({ currentBrewRendererPageNum: pageNumber });
},
handleTextChange : function(text){
//If there are errors, run the validator on every change to give quick feedback
let htmlErrors = this.state.htmlErrors;
if(htmlErrors.length) htmlErrors = Markdown.validate(text);
this.setState((prevState)=>({
brew : { ...prevState.brew, text: text },
htmlErrors : htmlErrors,
}));
localStorage.setItem(BREWKEY, text);
},
handleStyleChange : function(style){
this.setState((prevState)=>({
brew : { ...prevState.brew, style: style },
}));
localStorage.setItem(STYLEKEY, style);
},
handleSnipChange : function(snippet){
//If there are errors, run the validator on every change to give quick feedback
let htmlErrors = this.state.htmlErrors;
if(htmlErrors.length) htmlErrors = Markdown.validate(snippet);
this.setState((prevState)=>({
brew : { ...prevState.brew, snippets: snippet },
htmlErrors : htmlErrors,
}), ()=>{if(this.state.autoSave) this.trySave();});
},
handleMetaChange : function(metadata, field=undefined){
if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed
fetchThemeBundle(this, metadata.renderer, metadata.theme);
this.setState((prevState)=>({
brew : { ...prevState.brew, ...metadata },
}), ()=>{
localStorage.setItem(METAKEY, JSON.stringify({
// 'title' : this.state.brew.title,
// 'description' : this.state.brew.description,
'renderer' : this.state.brew.renderer,
'theme' : this.state.brew.theme,
'lang' : this.state.brew.lang
}));
});
;
},
save : async function(){
this.setState({
isSaving : true
useEffect(()=>{
trySaveRef.current = trySave;
unsavedChangesRef.current = unsavedChanges;
});
let brew = this.state.brew;
// Split out CSS to Style if CSS codefence exists
if(brew.text.startsWith('```css') && brew.text.indexOf('```\n\n') > 0) {
const index = brew.text.indexOf('```\n\n');
brew.style = `${brew.style ? `${brew.style}\n` : ''}${brew.text.slice(7, index - 1)}`;
brew.text = brew.text.slice(index + 5);
}
const handleSplitMove = ()=>{
editorRef.current.update();
};
const resetWarnUnsavedTimer = ()=>{
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
clearTimeout(warnUnsavedTimeout.current);
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
};
const trySave = async ()=>{
setIsSaving(true);
const updatedBrew = { ...currentBrew };
splitTextStyleAndMetadata(updatedBrew);
const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm;
updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1;
brew.pageCount=((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
const res = await request
.post(`/api${this.state.saveGoogle ? '?saveToGoogle=true' : ''}`)
.send(brew)
.post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`)
.send(updatedBrew)
.catch((err)=>{
this.setState({ isSaving: false, error: err });
setIsSaving(false);
setError(err);
});
setIsSaving(false);
if(!res) return;
brew = res.body;
const savedBrew = res.body;
localStorage.removeItem(BREWKEY);
localStorage.removeItem(STYLEKEY);
localStorage.removeItem(METAKEY);
window.location = `/edit/${brew.editId}`;
},
window.onbeforeunload = null;
window.location = `/edit/${savedBrew.editId}`;
};
renderSaveButton : function(){
if(this.state.isSaving){
return <Nav.item icon='fas fa-spinner fa-spin' className='save'>
save...
</Nav.item>;
} else {
return <Nav.item icon='fas fa-save' className='save' onClick={this.save}>
save
const renderSaveButton = ()=>{
// #1 - Currently saving, show SAVING
if(isSaving)
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
if(unsavedChanges && warnUnsavedChanges) {
resetWarnUnsavedTimer();
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
const text = elapsedTime === 0
? `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}.`
: `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}, and you haven't saved for ${elapsedTime} minutes.`;
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
Reminder...
<div className='errorContainer'>{text}</div>
</Nav.item>;
}
},
renderNavbar : function(){
return <Navbar>
// #3 - Unsaved changes exist, click to save, show SAVE NOW
if(unsavedChanges)
return <Nav.item className='save' onClick={trySave} color='blue' icon='fas fa-save'>save now</Nav.item>;
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
// #5 - Sandbox with no unsaved changes, and has never been saved, hide the button
if(sandbox)
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>;
};
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
const renderNavbar = ()=>(
<Navbar>
<Nav.section>
<Nav.item className='brewTitle'>{this.state.brew.title}</Nav.item>
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item>
</Nav.section>
<Nav.section>
{this.state.error ?
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
this.renderSaveButton()
}
{error
? <ErrorNavItem error={error} clearError={clearError} />
: renderSaveButton()}
<NewBrewItem />
<PrintNavItem />
<HelpNavItem />
<VaultNavItem />
<RecentNavItem />
<AccountNavItem />
</Nav.section>
</Navbar>;
},
</Navbar>
);
render : function(){
return <div className='newPage sitePage'>
{this.renderNavbar()}
return (
<div className='newPage sitePage'>
{renderNavbar()}
<div className='content'>
<SplitPane onDragFinish={this.handleSplitMove}>
<SplitPane onDragFinish={handleSplitMove}>
<Editor
ref={this.editor}
brew={this.state.brew}
onTextChange={this.handleTextChange}
onStyleChange={this.handleStyleChange}
onMetaChange={this.handleMetaChange}
onSnipChange={this.handleSnipChange}
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
themeBundle={this.state.themeBundle}
onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
ref={editorRef}
brew={currentBrew}
onBrewChange={handleBrewChange}
renderer={currentBrew.renderer}
userThemes={props.userThemes}
themeBundle={themeBundle}
onCursorPageChange={setCurrentEditorCursorPageNum}
onViewPageChange={setCurrentEditorViewPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors}
lang={this.state.brew.lang}
onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
text={currentBrew.text}
style={currentBrew.style}
renderer={currentBrew.renderer}
theme={currentBrew.theme}
themeBundle={themeBundle}
errors={HTMLErrors}
lang={currentBrew.lang}
onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
allowPrint={true}
/>
</SplitPane>
</div>
</div>;
}
});
</div>
);
};
module.exports = NewPage;
export default NewPage;
@@ -1,6 +1,17 @@
@import '@sharedStyles/colors.less';
.newPage {
.navItem.save {
background-color : @orange;
transition:all 0.2s;
&:hover { background-color : @green; }
&.neverSaved {
translate:-100%;
opacity: 0;
background-color :#333;
cursor:auto;
}
}
}
+34 -28
View File
@@ -1,31 +1,28 @@
require('./sharePage.less');
const React = require('react');
const { useState, useEffect, useCallback } = React;
const { Meta } = require('vitreum/headtags');
import './sharePage.less';
import React, { useState, useEffect, useCallback } from 'react';
import Headtags from '../../../../vitreum/headtags.js';
const Meta = Headtags.Meta;
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const MetadataNav = require('../../navbar/metadata.navitem.jsx');
const PrintNavItem = require('../../navbar/print.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const Account = require('../../navbar/account.navitem.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx';
import MetadataNav from '@navbar/metadata.navitem.jsx';
import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
import Account from '@navbar/account.navitem.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
const SharePage = (props)=>{
const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props;
const [state, setState] = useState({
themeBundle : {},
currentBrewRendererPageNum : 1,
});
const [themeBundle, setThemeBundle] = useState({});
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const handleBrewRendererPageChange = useCallback((pageNumber)=>{
setState((prevState)=>({
currentBrewRendererPageNum : pageNumber,
...prevState }));
setCurrentBrewRendererPageNum(pageNumber);
}, []);
const handleControlKeys = (e)=>{
@@ -40,11 +37,7 @@ const SharePage = (props)=>{
useEffect(()=>{
document.addEventListener('keydown', handleControlKeys);
fetchThemeBundle(
{ setState },
brew.renderer,
brew.theme
);
fetchThemeBundle(undefined, setThemeBundle, brew.renderer, brew.theme);
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
@@ -99,6 +92,19 @@ const SharePage = (props)=>{
<Nav.item color='blue' icon='fas fa-clone' href={`/new/${processShareId()}`}>
clone to new
</Nav.item>
<Nav.item
color='blue'
icon='fas fa-link'
onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${processShareId()}`);}}>
copy url
</Nav.item>
{currentBrewRendererPageNum > 1 &&
<Nav.item
color='blue'
icon='fas fa-hashtag'
onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${processShareId()}#p${currentBrewRendererPageNum}`);}}>
copy url (page {currentBrewRendererPageNum})
</Nav.item>}
</Nav.dropdown>
</>
)}
@@ -114,9 +120,9 @@ const SharePage = (props)=>{
lang={brew.lang}
renderer={brew.renderer}
theme={brew.theme}
themeBundle={state.themeBundle}
themeBundle={themeBundle}
onPageChange={handleBrewRendererPageChange}
currentBrewRendererPageNum={state.currentBrewRendererPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
allowPrint={true}
/>
</div>
@@ -124,4 +130,4 @@ const SharePage = (props)=>{
);
};
module.exports = SharePage;
export default SharePage;
+18 -14
View File
@@ -1,17 +1,17 @@
const React = require('react');
const { useState } = React;
const _ = require('lodash');
import React, { useState } from 'react';
import _ from 'lodash';
const ListPage = require('../basePages/listPage/listPage.jsx');
import ListPage from '../basePages/listPage/listPage.jsx';
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const Account = require('../../navbar/account.navitem.jsx');
const NewBrew = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const VaultNavitem = require('../../navbar/vault.navitem.jsx');
import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
import Account from '@navbar/account.navitem.jsx';
import NewBrew from '@navbar/newbrew.navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx';
import ErrorNavItem from '@navbar/error-navitem.jsx';
import VaultNavitem from '@navbar/vault.navitem.jsx';
const UserPage = (props)=>{
props = {
@@ -39,10 +39,14 @@ const UserPage = (props)=>{
}] : [])
];
const clearError = ()=>{
setError(null);
};
const navItems = (
<Navbar>
<Nav.section>
{error && (<ErrorNavItem error={error} parent={null}></ErrorNavItem>)}
{error && (<ErrorNavItem error={error} clearError={clearError}></ErrorNavItem>)}
<NewBrew />
<HelpNavItem />
<VaultNavitem />
@@ -57,4 +61,4 @@ const UserPage = (props)=>{
);
};
module.exports = UserPage;
export default UserPage;
+16 -16
View File
@@ -1,19 +1,18 @@
/*eslint max-lines: ["warn", {"max": 400, "skipBlankLines": true, "skipComments": true}]*/
/*eslint max-params:["warn", { max: 10 }], */
require('./vaultPage.less');
import './vaultPage.less';
import React, { useState, useEffect, useRef } from 'react';
const React = require('react');
const { useState, useEffect, useRef } = React;
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const Account = require('../../navbar/account.navitem.jsx');
const NewBrew = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx');
const BrewItem = require('../basePages/listPage/brewItem/brewItem.jsx');
const SplitPane = require('../../../../shared/naturalcrit/splitPane/splitPane.jsx');
const ErrorIndex = require('../errorPage/errors/errorIndex.js');
import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
import Account from '@navbar/account.navitem.jsx';
import NewBrew from '@navbar/newbrew.navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx';
import BrewItem from '../basePages/listPage/brewItem/brewItem.jsx';
import SplitPane from '@components/splitPane/splitPane.jsx';
import ErrorIndex from '../errorPage/errors/errorIndex.js';
import request from '../../utils/request-middleware.js';
@@ -101,7 +100,7 @@ const VaultPage = (props)=>{
const title = titleRef.current.value || '';
const author = authorRef.current.value || '';
const count = countRef.current.value || 10;
const count = countRef.current.value || 20;
const v3 = v3Ref.current.checked != false;
const legacy = legacyRef.current.checked != false;
const sortOption = sort || 'title';
@@ -288,7 +287,8 @@ const VaultPage = (props)=>{
const renderPaginationControls = ()=>{
if(!totalBrews || totalBrews < 10) return null;
const countInt = parseInt(brewCollection.length || 20);
const countInt = parseInt(countRef.current.value || 20);
const totalPages = Math.ceil(totalBrews / countInt);
let startPage, endPage;
@@ -429,4 +429,4 @@ const VaultPage = (props)=>{
);
};
module.exports = VaultPage;
export default VaultPage;
@@ -1,14 +1,18 @@
@import '@sharedStyles/core.less';
.vaultPage {
height : 100%;
overflow-y : hidden;
background-color : #2C3E50;
*:not(input) { user-select : none; }
.form {
background:white;
}
:where(.content .dataGroup) {
width : 100%;
height : 100%;
background : white;
&.form .brewLookup {
position : relative;
@@ -171,7 +175,6 @@
max-height : 100%;
padding : 70px 50px;
overflow-y : scroll;
background-color : #2C3E50;
container-type : inline-size;
h3 { font-size : 25px; }
@@ -0,0 +1,97 @@
import React, { useState, useEffect, useRef } from 'react';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import _ from 'lodash';
const AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
export default function useCommonEditPageFunctions(dependencies) {
const {
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
currentBrew,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
hbfm,
autoSaveEnabled,
setAutoSaveEnabled,
setWarnUnsavedChanges,
trySaveRef,
sandbox,
saveGoogle = false,
unsavedChangesRef,
setUnsavedChanges,
lastSavedBrew
} = dependencies;
//==--------- Page setup ----------==//
useEffect(()=>{
const autoSavePref = !sandbox && JSON.parse(localStorage.getItem(AUTOSAVE_KEY) ?? true);
setAutoSaveEnabled(autoSavePref);
console.log(autoSavePref)
setWarnUnsavedChanges(!autoSavePref);
setHTMLErrors(hbfm.validate(currentBrew.text));
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
const handleControlKeys = (e)=>{
if(!(e.ctrlKey || e.metaKey)) return;
if(e.keyCode === 83) trySaveRef.current(true, true, saveGoogle);
if(e.keyCode === 80) printCurrentBrew();
if([83, 80].includes(e.keyCode)) {
e.stopPropagation();
e.preventDefault();
}
};
document.addEventListener('keydown', handleControlKeys);
window.onbeforeunload = ()=>{
if(unsavedChangesRef.current)
return 'You have unsaved changes!';
};
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
window.onBeforeUnload = null;
};
}, []);
//======----- Check for unsaved changes and autosave if enabled -----======
useEffect(()=>{
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
setUnsavedChanges(hasChange);
if(autoSaveEnabled) trySaveRef.current(false, hasChange, saveGoogle);
}, [currentBrew]);
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
if(subfield == 'renderer' || subfield == 'theme')
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
//If there are HTML errors, run the validator on every change to give quick feedback
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
setHTMLErrors(hbfm.validate(value));
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
if(useLocalStorage) {
if(field == 'text') localStorage.setItem(BREWKEY, value);
if(field == 'style') localStorage.setItem(STYLEKEY, value);
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
renderer : value.renderer,
theme : value.theme,
lang : value.lang
}));
}
};
return {
handleBrewChange
}
}

Some files were not shown because too many files have changed in this diff Show More