0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2025-12-28 15:42:38 +00:00

merge from master

This commit is contained in:
Víctor Losada Hernández
2024-09-16 23:11:27 +02:00
53 changed files with 18661 additions and 16491 deletions

View File

@@ -67,6 +67,9 @@ jobs:
- run:
name: Test - Definition Lists
command: npm run test:definition-lists
- run:
name: Test - Hard Breaks
command: npm run test:hard-breaks
- run:
name: Test - Variables
command: npm run test:variables

View File

@@ -1,79 +0,0 @@
module.exports = {
root : true,
parserOptions : {
ecmaVersion : 2021,
sourceType : 'module',
ecmaFeatures : {
jsx : true
}
},
env : {
browser : true,
node : true
},
plugins : ['react', 'jest'],
rules : {
/** Errors **/
'camelcase' : ['error', { properties: 'never' }],
//'func-style' : ['error', 'expression', { allowArrowFunctions: true }],
'no-array-constructor' : 'error',
'no-iterator' : 'error',
'no-nested-ternary' : 'error',
'no-new-object' : 'error',
'no-proto' : 'error',
'react/jsx-no-bind' : ['error', { allowArrowFunctions: true }],
'react/jsx-uses-react' : 'error',
'react/prefer-es6-class' : ['error', 'never'],
'jest/valid-expect' : ['error', { maxArgs: 3 }],
/** Warnings **/
'max-lines' : ['warn', {
max : 200,
skipComments : true,
skipBlankLines : true,
}],
'max-depth' : ['warn', { max: 4 }],
'max-params' : ['warn', { max: 5 }],
'no-restricted-syntax' : ['warn', 'ClassDeclaration', 'SwitchStatement'],
'no-unused-vars' : ['warn', {
vars : 'all',
args : 'none',
varsIgnorePattern : 'config|_|cx|createClass'
}],
'react/jsx-uses-vars' : 'warn',
/** Fixable **/
'arrow-parens' : ['warn', 'always'],
'brace-style' : ['warn', '1tbs', { allowSingleLine: true }],
'jsx-quotes' : ['warn', 'prefer-single'],
'no-var' : 'warn',
'prefer-const' : 'warn',
'prefer-template' : 'warn',
'quotes' : ['warn', 'single', { 'allowTemplateLiterals': true }],
'semi' : ['warn', 'always'],
/** Whitespace **/
'array-bracket-spacing' : ['warn', 'never'],
'arrow-spacing' : ['warn', { before: false, after: false }],
'comma-spacing' : ['warn', { before: false, after: true }],
'indent' : ['warn', 'tab', { 'MemberExpression': 'off' }],
'keyword-spacing' : ['warn', {
before : true,
after : true,
overrides : {
if : { 'before': false, 'after': false }
}
}],
'key-spacing' : ['warn', {
multiLine : { beforeColon: true, afterColon: true, align: 'colon' },
singleLine : { beforeColon: false, afterColon: true }
}],
'linebreak-style' : 'off',
'no-trailing-spaces' : 'warn',
'no-whitespace-before-property' : 'warn',
'object-curly-spacing' : ['warn', 'always'],
'react/jsx-indent-props' : ['warn', 'tab'],
'space-in-parens' : ['warn', 'never'],
'template-curly-spacing' : ['warn', 'never'],
}
};

4
.gitattributes vendored
View File

@@ -1 +1,3 @@
package-lock.json binary
package-lock.json binary
*.json text eol=lf

36
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,36 @@
<!--
Before submitting a Pull Request, please consider the following to speed up reviews:
- 👷‍♀️ Create small PRs. Large PRs can usually be broken down into incremental PRs.
- 🚩 Do you already have several open PRs? Consider finishing or asking for help with existing PRs first.
- 🔧 Does your PR reference a discussed and approved issue, especially for personal or edge-case requests?
- 💡 Is the solution agreed upon? Save rework time by discussing strategy before coding.
-->
## Description
## Related Issues or Discussions
- Closes #
## QA Instructions, Screenshots, Recordings
_Please replace this line with instructions on how to test or view your changes, as well as any before/after
images for UI changes._
### Reviewer Checklist
_Please replace the list below with specific features you want reviewers to look at._
*Reviewers, refer to this list when testing features, or suggest new items *
- [ ] Verify new features are functional
- [ ] Feature A does X
- [ ] Feature B does Y
- [ ] Verify old features have not broken
- [ ] Feature Z can still be used
- [ ] Test for edge cases / try to break things
- [ ] Feature A handles negative numbers
- [ ] Identify opportunities for simplification and refactoring
- [ ] Check for code legibility and appropriate comments
<details><summary>Copy this list</summary>

View File

@@ -84,9 +84,105 @@ pre {
## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### Tuesday 8/13/2024 - v3.14.1
{{taskList
### Wednesday 9/04/2024 - v3.15.0
{{taskList
##### 5e-Cleric, abquintic, calculuschild, Gazook89, G-Ambatte, Ericsheid, Kaiburr
* [x] New {{openSans **VAULT** {{fas,fa-dungeon}}}} page 🎉🎉🎉
:
All **PUBLISHED** brews ({{openSans :fas_circle_info: **Properties**}} menu) will be searchable, by title or author, and filtered by renderer. More features and adjustments will be coming.
:
Note: If any of your own brews are not showing up in search (particularly if stored on Google Drive), please edit and re-save to ensure our database has the data needed from document to be searchable.
Fixes issue [#697](https://github.com/naturalcrit/homebrewery/issues/697)
##### Gazook89
* [x] Auto-focus on text editor when switching editor tabs
}}
### Wednesday 8/28/2024 - v3.14.3
{{taskList
##### calculuschild, G-Ambatte
* [x] New {{openSans **IMAGES → {{fac,image-wrap-left}} IMAGE WRAP LEFT/RIGHT**}} snippets
Fixes issue [#380](https://github.com/naturalcrit/homebrewery/issues/380)
* [x] Fix v3.14.2 bug with `` failing after tables
##### 5e-Cleric
* [x] Fix Account page crash when not logged in
Fixes issue [#3605](https://github.com/naturalcrit/homebrewery/issues/3605)
##### abquintic
* [x] Fix jump hotkeys conflicting with `CTRL + SHIFT`. Preview and Source movement shortcuts now use `CTRL + SHIFT + META + LEFT\RIGHTARROW`
##### G-Ambatte
* [x] Fix display issue with image wrap icons
}}
### Tuesday 8/27/2024 - v3.14.2
{{taskList
##### calculuschild
* [x] Reroute invalid urls to homepage
Fixes issues [#3269](https://github.com/naturalcrit/homebrewery/issues/3629)
* [x] Background dependency updates
##### G-Ambatte
* [x] Add route to get brew styling via `/css/shareId`
Fixes issues [#1097](https://github.com/naturalcrit/homebrewery/issues/1097)
* [x] Fix `:emojis:` preventing code folding
Fixes issues [#3604](https://github.com/naturalcrit/homebrewery/issues/3604)
* [x] Fix mask image warping when rotated and stretched
Fixes issues [#3636](https://github.com/naturalcrit/homebrewery/issues/3636)
* [x] Fix Table of Contents uppercasing
Fixes issues [#3572](https://github.com/naturalcrit/homebrewery/issues/3572)
##### abquintic
* [x] Create globally unique Header IDs across pages
Fixes issues [#1430](https://github.com/naturalcrit/homebrewery/issues/1430)
* [x] Fix colon `` being parsed in codeblocks
* [x] Prevent crashes when loading undefined renderer or theme bundle
* [x] Add Jump-To hotkeys
* Use `CTRL/META + SHIFT + LEFTARROW` to brewJump
* Use `CTRL/META + SHIFT + RIGHTARROW` to sourceJump
* [x] Prevent reload from clobbering modified fresh clones
##### 5e-Cleric, Gazook89
* [x] Viewer tools for zoom/page navigation
}}
### Tuesday 8/13/2024 - v3.14.1
{{taskList
##### abquintic
* [x] Allow Table of Contents to flow across columns
@@ -129,16 +225,13 @@ Fixes issues [#3613](https://github.com/naturalcrit/homebrewery/issues/3613)
Fixes issues [#3622](https://github.com/naturalcrit/homebrewery/issues/3622)
##### calculuschild
* [x] Fix `/migrate` page using an editor context instead of share context
##### 5e-Cleric
* [x] Fix Monster Stat Blocks losing color in Safari
}}
\page
@@ -509,7 +602,7 @@ Fixes issue [#2729](https://github.com/naturalcrit/homebrewery/issues/2729),
### Thursday 17/08/2023 - v3.9.2
{{taskList
##### Calculuschild
##### calculuschild
* [x] Fix links to certain old Google Drive files
@@ -567,7 +660,7 @@ Fixes issue [#1924](https://github.com/naturalcrit/homebrewery/issues/1924)
### Friday 02/06/2023 - v3.9.0
{{taskList
##### Calculuschild
##### calculuschild
* [x] Fix some files not showing up on userpage when user has a large number of brews in Google Drive
@@ -664,7 +757,7 @@ Fixes issues [#2731](https://github.com/naturalcrit/homebrewery/issues/2731)
### Monday 13/03/2023 - v3.7.2
{{taskList
##### Calculuschild
##### calculuschild
* [x] Fix wide Monster Stat Blocks not spanning columns on Legacy
}}
@@ -687,7 +780,7 @@ Fixes issues [#1569](https://github.com/naturalcrit/homebrewery/issues/1569)
* [x] Updated the Google Drive icon
* [x] Backend fix to unit tests failing intermittently
##### Calculuschild
##### calculuschild
* [x] Fix PDF pixelation on CoverPage text outlines
}}
@@ -699,7 +792,7 @@ Fixes issues [#1569](https://github.com/naturalcrit/homebrewery/issues/1569)
**NOTE:** Some new snippets will now show a {{beta BETA}} tag. Feel free to use them, but be aware we may change how they work depending on your feedback.
}}
##### Calculuschild
##### calculuschild
* [x] New {{openSans **IMAGES → WATERCOLOR EDGE** {{fac,mask-edge}} }} and {{openSans **WATERCOLOR CORNER** {{fac,mask-corner}} }} snippets for V3, which adds a stylish watercolor texture to the edge of your images! (Thanks to /u/flamableconcrete on Reddit for providing these image masks!)
@@ -843,7 +936,7 @@ Fixes issues [#1670](https://github.com/naturalcrit/homebrewery/issues/1670)
### Thursday 28/10/2022 - v3.3.1
{{taskList
##### Calculuschild
##### calculuschild
* [x] Fixes to several broken CSS styles from v3.3.0
@@ -858,7 +951,7 @@ Fixes issues [#2468](https://github.com/naturalcrit/homebrewery/issues/2468)
### Friday 19/10/2022 - v3.3.0
{{taskList
##### Calculuschild
##### calculuschild
* [x] Fix for tables broken by Chrome v106
@@ -941,7 +1034,7 @@ Fixes issues [#2317](https://github.com/naturalcrit/homebrewery/issues/2317), [
### Wednesday 31/08/2022 - v3.2.1
{{taskList
##### Calculuschild
##### calculuschild
* [x] Reference Links should now work inside tables
@@ -967,7 +1060,7 @@ Fixes issues [#2317](https://github.com/naturalcrit/homebrewery/issues/2317), [
### Saturday 27/08/2022 - v3.2.0
{{taskList
##### Calculuschild
##### calculuschild
* [x] The V3 renderer is now the default for new brews.
@@ -994,7 +1087,7 @@ Fixes issues [#2317](https://github.com/naturalcrit/homebrewery/issues/2317), [
### Thursday 09/06/2022 - v3.1.1
{{taskList
##### Calculuschild:
##### calculuschild:
* [x] Fixed class table decorations appearing on top of the table in PDF output.
@@ -1838,4 +1931,4 @@ Massive changelog incoming:
* Added `phb.standalone.css` plus a build system for creating it
* Added page numbers and footer text
* Page accent now flips each page
* Page accent now flips each page

View File

@@ -1,12 +1,13 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./brewRenderer.less');
const React = require('react');
const { useState, useRef, useEffect } = React;
const { useState, useRef, useEffect, useCallback } = React;
const _ = require('lodash');
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
const Markdown = require('naturalcrit/markdown.js');
const ErrorBar = require('./errorBar/errorBar.jsx');
const ToolBar = require('./toolBar/toolBar.jsx');
//TODO: move to the brew renderer
const RenderWarnings = require('homebrewery/renderWarnings/renderWarnings.jsx');
@@ -48,22 +49,25 @@ let rawPages = [];
const BrewRenderer = (props)=>{
props = {
text : '',
style : '',
renderer : 'legacy',
theme : '5ePHB',
lang : '',
errors : [],
currentEditorPage : 0,
themeBundle : {},
text : '',
style : '',
renderer : 'legacy',
theme : '5ePHB',
lang : '',
errors : [],
currentEditorCursorPageNum : 0,
currentEditorViewPageNum : 0,
currentBrewRendererPageNum : 0,
themeBundle : {},
onPageChange : ()=>{},
...props
};
const [state, setState] = useState({
viewablePageNumber : 0,
height : PAGE_HEIGHT,
isMounted : false,
visibility : 'hidden',
height : PAGE_HEIGHT,
isMounted : false,
visibility : 'hidden',
zoom : 100
});
const mainRef = useRef(null);
@@ -85,38 +89,27 @@ const BrewRenderer = (props)=>{
}));
};
const handleScroll = (e)=>{
const target = e.target;
setState((prevState)=>({
...prevState,
viewablePageNumber : Math.floor(target.scrollTop / target.scrollHeight * rawPages.length)
}));
};
const updateCurrentPage = useCallback(_.throttle((e)=>{
const { scrollTop, clientHeight, scrollHeight } = e.target;
const totalScrollableHeight = scrollHeight - clientHeight;
const currentPageNumber = Math.max(Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length), 1);
props.onPageChange(currentPageNumber);
}, 200), []);
const isInView = (index)=>{
if(!state.isMounted)
return false;
if(index == props.currentEditorPage) //Already rendered before this step
if(index == props.currentEditorCursorPageNum - 1) //Already rendered before this step
return false;
if(Math.abs(index - state.viewablePageNumber) <= 3)
if(Math.abs(index - props.currentBrewRendererPageNum - 1) <= 3)
return true;
return false;
};
const renderPageInfo = ()=>{
return <div className='pageInfo' ref={mainRef}>
<div>
{props.renderer}
</div>
<div>
{state.viewablePageNumber + 1} / {rawPages.length}
</div>
</div>;
};
const renderDummyPage = (index)=>{
return <div className='phb page' id={`p${index + 1}`} key={index}>
<i className='fas fa-spinner fa-spin' />
@@ -148,7 +141,7 @@ const BrewRenderer = (props)=>{
renderedPages.length = 0;
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first
renderedPages[props.currentEditorPage] = renderPage(rawPages[props.currentEditorPage], props.currentEditorPage);
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
_.forEach(rawPages, (page, index)=>{
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
@@ -186,11 +179,19 @@ const BrewRenderer = (props)=>{
document.dispatchEvent(new MouseEvent('click'));
};
//Toolbar settings:
const handleZoom = (newZoom)=>{
setState((prevState)=>({
...prevState,
zoom : newZoom
}));
};
return (
<>
{/*render dummy page while iFrame is mounting.*/}
{!state.isMounted
? <div className='brewRenderer' onScroll={handleScroll}>
? <div className='brewRenderer' onScroll={updateCurrentPage}>
<div className='pages'>
{renderDummyPage(1)}
</div>
@@ -198,11 +199,13 @@ const BrewRenderer = (props)=>{
: null}
<ErrorBar errors={props.errors} />
<div className='popups'>
<div className='popups' ref={mainRef}>
<RenderWarnings />
<NotificationPopup />
</div>
<ToolBar onZoomChange={handleZoom} currentPage={props.currentBrewRendererPageNum} totalPages={rawPages.length}/>
{/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
style={{ width: '100%', height: '100%', visibility: state.visibility }}
@@ -210,23 +213,23 @@ const BrewRenderer = (props)=>{
onClick={()=>{emitClick();}}
>
<div className={'brewRenderer'}
onScroll={handleScroll}
onScroll={updateCurrentPage}
onKeyDown={handleControlKeys}
tabIndex={-1}
style={{ height: state.height }}>
{/* Apply CSS from Style tab and render pages from Markdown tab */}
{state.isMounted
&&
<>
{renderStyle()}
<div className='pages' lang={`${props.lang || 'en'}`}>
<div className='pages' lang={`${props.lang || 'en'}`} style={{ zoom: `${state.zoom}%` }}>
{renderPages()}
</div>
</>
}
</div>
</Frame>
{renderPageInfo()}
</>
);
};

View File

@@ -1,8 +1,9 @@
@import (multiple, less) 'shared/naturalcrit/styles/reset.less';
.brewRenderer {
will-change : transform;
overflow-y : scroll;
overflow-y : scroll;
will-change : transform;
padding-top : 30px;
:where(.pages) {
margin : 30px 0px;
& > :where(.page) {
@@ -14,66 +15,31 @@
box-shadow : 1px 4px 14px #000000;
}
}
&::-webkit-scrollbar {
width: 20px;
&:horizontal{
height: 20px;
width:auto;
width : 20px;
&:horizontal {
width : auto;
height : 20px;
}
&-thumb {
background: linear-gradient(90deg, #d3c1af 15px, #00000000 15px);
&:horizontal{
background: linear-gradient(0deg, #d3c1af 15px, #00000000 15px);
}
}
&-corner {
visibility: hidden;
background : linear-gradient(90deg, #D3C1AF 15px, #00000000 15px);
&:horizontal { background : linear-gradient(0deg, #D3C1AF 15px, #00000000 15px); }
}
&-corner { visibility : hidden; }
}
}
.pane { position : relative; }
.pageInfo {
position : absolute;
right : 17px;
bottom : 0;
z-index : 1000;
font-size : 10px;
font-weight : 800;
color : white;
background-color : #333333;
div {
display : inline-block;
padding : 8px 10px;
&:not(:last-child) { border-right : 1px solid #666666; }
}
}
.ppr_msg {
position : absolute;
bottom : 0;
left : 0px;
z-index : 1000;
padding : 8px 10px;
font-size : 10px;
font-weight : 800;
color : white;
background-color : #333333;
}
@media print {
.toolBar { display : none; }
.brewRenderer {
height: 100%;
overflow-y: unset;
height : 100%;
padding-top : unset;
overflow-y : unset;
.pages {
margin: 0px;
&>.page {
box-shadow: unset;
}
margin : 0px;
& > .page { box-shadow : unset; }
}
}
}

View File

@@ -4,7 +4,7 @@ const _ = require('lodash');
import Dialog from '../../../components/dialog.jsx';
const DISMISS_KEY = 'dismiss_notification12-04-23';
const DISMISS_KEY = 'dismiss_notification04-09-24';
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
const NotificationPopup = ()=>{
@@ -15,11 +15,12 @@ const NotificationPopup = ()=>{
<small>This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:</small>
</div>
<ul>
<li key='psa'>
<em>Don't store IMAGES in Google Drive</em><br />
Google Drive is not an image service, and will block images from being used
in brews if they get more views than expected. Google has confirmed they won't fix
this, so we recommend you look for another image hosting service such as imgur, ImgBB or Google Photos.
<li key='Vault'>
<em>Search brews with our new page!</em><br />
We have been working very hard in making this possible, now you can share your work and look at it in the new <a href='/vault'>Vault</a> page!
All PUBLISHED brews will be available to anyone searching there, by title or author, and filtering by renderer.
More features will be coming.
</li>
<li key='googleDriveFolder'>

View File

@@ -1,9 +1,10 @@
.popups {
position : fixed;
top : @navbarHeight;
top : calc(@navbarHeight + @viewerToolsHeight);
right : 24px;
z-index : 10001;
width : 450px;
margin-top : 5px;
}
.notificationPopup {

View File

@@ -0,0 +1,164 @@
require('./toolBar.less');
const React = require('react');
const { useState, useEffect } = React;
const _ = require('lodash');
const MAX_ZOOM = 300;
const MIN_ZOOM = 10;
const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
const [zoomLevel, setZoomLevel] = useState(100);
const [pageNum, setPageNum] = useState(currentPage);
const [toolsVisible, setToolsVisible] = useState(true);
useEffect(()=>{
onZoomChange(zoomLevel);
}, [zoomLevel]);
useEffect(()=>{
setPageNum(currentPage);
}, [currentPage]);
const handleZoomButton = (zoom)=>{
setZoomLevel(_.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
};
const handlePageInput = (pageInput)=>{
if(/[0-9]/.test(pageInput))
setPageNum(parseInt(pageInput)); // input type is 'text', so `page` comes in as a string, not number.
};
const scrollToPage = (pageNumber)=>{
pageNumber = _.clamp(pageNumber, 1, totalPages);
const iframe = document.getElementById('BrewRenderer');
const brewRenderer = iframe?.contentWindow?.document.querySelector('.brewRenderer');
const page = brewRenderer?.querySelector(`#p${pageNumber}`);
page?.scrollIntoView({ block: 'start' });
setPageNum(pageNumber);
};
const calculateChange = (mode)=>{
const iframe = document.getElementById('BrewRenderer');
const iframeWidth = iframe.getBoundingClientRect().width;
const iframeHeight = iframe.getBoundingClientRect().height;
const pages = iframe.contentWindow.document.getElementsByClassName('page');
let desiredZoom = 0;
if(mode == 'fill'){
// find widest page, in case pages are different widths, so that the zoom is adapted to not cut the widest page off screen.
const widestPage = _.maxBy([...pages], 'offsetWidth').offsetWidth;
desiredZoom = (iframeWidth / widestPage) * 100;
} else if(mode == 'fit'){
// find the page with the largest single dim (height or width) so that zoom can be adapted to fit it.
const minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity);
desiredZoom = minDimRatio * 100;
}
const margin = 5; // extra space so page isn't edge to edge (not truly "to fill")
const deltaZoom = (desiredZoom - zoomLevel) - margin;
return deltaZoom;
};
return (
<div className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`}>
<button className='toggleButton' title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{setToolsVisible(!toolsVisible);}}><i className='fas fa-glasses' /></button>
{/*v=====----------------------< Zoom Controls >---------------------=====v*/}
<div className='group'>
<button
id='fill-width'
className='tool'
onClick={()=>handleZoomButton(zoomLevel + calculateChange('fill'))}
>
<i className='fac fit-width' />
</button>
<button
id='zoom-to-fit'
className='tool'
onClick={()=>handleZoomButton(zoomLevel + calculateChange('fit'))}
>
<i className='fac zoom-to-fit' />
</button>
<button
id='zoom-out'
className='tool'
onClick={()=>handleZoomButton(zoomLevel - 20)}
disabled={zoomLevel <= MIN_ZOOM}
>
<i className='fas fa-magnifying-glass-minus' />
</button>
<input
id='zoom-slider'
className='range-input tool'
type='range'
name='zoom'
list='zoomLevels'
min={MIN_ZOOM}
max={MAX_ZOOM}
step='1'
value={zoomLevel}
onChange={(e)=>handleZoomButton(parseInt(e.target.value))}
/>
<datalist id='zoomLevels'>
<option value='100' />
</datalist>
<button
id='zoom-in'
className='tool'
onClick={()=>handleZoomButton(zoomLevel + 20)}
disabled={zoomLevel >= MAX_ZOOM}
>
<i className='fas fa-magnifying-glass-plus' />
</button>
</div>
{/*v=====----------------------< Page Controls >---------------------=====v*/}
<div className='group'>
<button
id='previous-page'
className='previousPage tool'
onClick={()=>scrollToPage(pageNum - 1)}
disabled={pageNum <= 1}
>
<i className='fas fa-arrow-left'></i>
</button>
<div className='tool'>
<input
id='page-input'
className='text-input'
type='text'
name='page'
inputMode='numeric'
pattern='[0-9]'
value={pageNum}
onClick={(e)=>e.target.select()}
onChange={(e)=>handlePageInput(e.target.value)}
onBlur={()=>scrollToPage(pageNum)}
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
/>
<span id='page-count'>/ {totalPages}</span>
</div>
<button
id='next-page'
className='tool'
onClick={()=>scrollToPage(pageNum + 1)}
disabled={pageNum >= totalPages}
>
<i className='fas fa-arrow-right'></i>
</button>
</div>
</div>
);
};
module.exports = ToolBar;

View File

@@ -0,0 +1,128 @@
@import (less) './client/icons/customIcons.less';
.toolBar {
position : absolute;
z-index : 1;
box-sizing : border-box;
display : flex;
flex-wrap : wrap;
gap : 8px 30px;
align-items : center;
justify-content : center;
width : 100%;
height : auto;
padding : 2px 0;
font-family : 'Open Sans', sans-serif;
color : #CCCCCC;
background-color : #555555;
& > *:not(.toggleButton) {
opacity: 1;
transition: all .2s ease;
}
.group {
box-sizing : border-box;
display : flex;
gap : 0 3px;
align-items : center;
justify-content : center;
height : 28px;
}
.tool {
display : flex;
align-items : center;
}
input {
position : relative;
height : 1.5em;
padding : 2px 5px;
font-family : 'Open Sans', sans-serif;
color : #000000;
background : #EEEEEE;
border : 1px solid gray;
&:focus { outline : 1px solid #D3D3D3; }
// `.range-input` if generic to all range inputs, or `#zoom-slider` if only for zoom slider
&.range-input {
padding : 2px 0;
color : #D3D3D3;
accent-color : #D3D3D3;
&::-webkit-slider-thumb, &::-moz-slider-thumb {
width : 5px;
height : 5px;
cursor : pointer;
outline : none;
}
&:hover::after {
position : absolute;
bottom : -30px;
left : 50%;
z-index : 1;
display : grid;
place-items : center;
width : 4ch;
height : 1.2lh;
pointer-events : none;
content : attr(value);
background-color : #555555;
border : 1px solid #A1A1A1;
transform : translate(-50%, 50%);
}
}
// `.text-input` if generic to all range inputs, or `#page-input` if only for current page input
&#page-input {
width : 4ch;
margin-right : 1ch;
text-align : center;
}
}
button {
box-sizing : content-box;
display : flex;
align-items : center;
justify-content : center;
width : auto;
min-width : 46px;
height : 100%;
padding : 0 0px;
font-weight : unset;
color : inherit;
background-color : unset;
&:hover { background-color : #444444; }
&:focus { outline : 1px solid #D3D3D3; }
&:disabled {
color : #777777;
background-color : unset !important;
}
i {
font-size:1.2em;
}
}
&.hidden {
width: 32px;
transition: all .3s ease;
flex-wrap:nowrap;
overflow: hidden;
background-color: unset;
opacity: .5;
& > *:not(.toggleButton) {
opacity: 0;
transition: all .2s ease;
}
}
}
button.toggleButton {
z-index : 5;
position:absolute;
left: 0;
width: 32px;
min-width: unset;
}

View File

@@ -1,9 +1,8 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
/*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 cx = require('classnames');
const dedent = require('dedent-tabs').default;
const Markdown = require('../../../shared/naturalcrit/markdown.js');
@@ -22,6 +21,7 @@ const DEFAULT_STYLE_TEXT = dedent`
color: black;
}`;
let isJumping = false;
const Editor = createClass({
displayName : 'Editor',
@@ -37,8 +37,15 @@ const Editor = createClass({
onMetaChange : ()=>{},
reportError : ()=>{},
onCursorPageChange : ()=>{},
onViewPageChange : ()=>{},
editorTheme : 'default',
renderer : 'legacy'
renderer : 'legacy',
currentEditorCursorPageNum : 1,
currentEditorViewPageNum : 1,
currentBrewRendererPageNum : 1,
};
},
getInitialState : function() {
@@ -56,9 +63,15 @@ const Editor = createClass({
isMeta : function() {return this.state.view == 'meta';},
componentDidMount : function() {
this.updateEditorSize();
this.highlightCustomMarkdown();
window.addEventListener('resize', this.updateEditorSize);
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));
const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
if(editorTheme) {
@@ -73,13 +86,35 @@ const Editor = createClass({
},
componentDidUpdate : function(prevProps, prevState, snapshot) {
this.highlightCustomMarkdown();
if(prevProps.moveBrew !== this.props.moveBrew) {
if(prevProps.moveBrew !== this.props.moveBrew)
this.brewJump();
};
if(prevProps.moveSource !== this.props.moveSource) {
if(prevProps.moveSource !== this.props.moveSource)
this.sourceJump();
};
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){
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 == LEFTARROW_KEY || e.keyCode == RIGHTARROW_KEY) {
e.stopPropagation();
e.preventDefault();
}
},
updateEditorSize : function() {
@@ -90,6 +125,20 @@ const Editor = createClass({
}
},
updateCurrentCursorPage : function(cursor) {
const lines = this.props.brew.text.split('\n').slice(0, cursor.line + 1);
const pageRegex = this.props.brew.renderer == 'V3' ? /^\\page$/ : /\\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(0, topScrollLine + 1);
const pageRegex = this.props.brew.renderer == 'V3' ? /^\\page$/ : /\\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);
},
@@ -98,19 +147,10 @@ const Editor = createClass({
this.props.setMoveArrows(newView === 'text');
this.setState({
view : newView
}, this.updateEditorSize); //TODO: not sure if updateeditorsize needed
},
getCurrentPage : function(){
const lines = this.props.brew.text.split('\n').slice(0, this.codeEditor.current.getCursorPosition().line + 1);
return _.reduce(lines, (r, line)=>{
if(
(this.props.renderer == 'legacy' && line.indexOf('\\page') !== -1)
||
(this.props.renderer == 'V3' && line.match(/^\\page$/))
) r++;
return r;
}, 1);
}, ()=>{
this.codeEditor.current?.codeMirror.focus();
this.updateEditorSize();
}); //TODO: not sure if updateeditorsize needed
},
highlightCustomMarkdown : function(){
@@ -119,8 +159,19 @@ const Editor = createClass({
const codeMirror = this.codeEditor.current.codeMirror;
codeMirror.operation(()=>{ // Batch CodeMirror styling
const foldLines = [];
//reset custom text styles
const customHighlights = codeMirror.getAllMarks().filter((mark)=>!mark.__isFold); //Don't undo code folding
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 editorPageCount = 2; // start page count from page 2
@@ -132,6 +183,11 @@ const Editor = createClass({
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(/^\\page$/))) {
@@ -155,7 +211,7 @@ const Editor = createClass({
// definition lists
if(line.includes('::')){
if(/^:*$/.test(line) == true){ return };
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){
@@ -163,10 +219,10 @@ const Editor = createClass({
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];
let colons = /::/g;
let colonMatches = colons.exec(match[2]);
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'} )
codeMirror.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight' });
}
}
}
@@ -176,12 +232,12 @@ const Editor = createClass({
let startIndex = line.indexOf('^');
const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy;
const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy;
while (startIndex >= 0) {
superRegex.lastIndex = subRegex.lastIndex = startIndex;
let isSuper = false;
let match = subRegex.exec(line) || superRegex.exec(line);
if (match) {
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' });
}
@@ -231,20 +287,20 @@ const Editor = createClass({
while (startIndex >= 0) {
emojiRegex.lastIndex = startIndex;
let match = emojiRegex.exec(line);
if (match) {
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)
tokens = tokens[0].tokens.filter((t)=>t.type == 'emoji');
if(!tokens.length)
return;
let startPos = { line: lineNumber, ch: match.index };
let endPos = { line: lineNumber, ch: match.index + match[0].length };
const startPos = { line: lineNumber, ch: match.index };
const endPos = { line: lineNumber, ch: match.index + match[0].length };
// Iterate over conflicting marks and clear them
var marks = codeMirror.findMarks(startPos, endPos);
const marks = codeMirror.findMarks(startPos, endPos);
marks.forEach(function(marker) {
marker.clear();
if(!marker.__isFold) marker.clear();
});
codeMirror.markText(startPos, endPos, { className: 'emoji' });
}
@@ -257,75 +313,93 @@ const Editor = createClass({
}
},
brewJump : function(targetPage=this.getCurrentPage()){
if(!window) return;
// console.log(`Scroll to: p${targetPage}`);
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
if(!window || 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 interimPos = targetPos >= 0 ? -30 : 30;
const bounceDelay = 100;
const scrollDelay = 500;
if(!this.throttleBrewMove) {
this.throttleBrewMove = _.throttle((currentPos, interimPos, targetPos)=>{
brewRenderer.scrollTo({ top: currentPos + interimPos, behavior: 'smooth' });
setTimeout(()=>{
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' });
}, bounceDelay);
}, scrollDelay, { leading: true, trailing: false });
const checkIfScrollComplete = ()=>{
let scrollingTimeout;
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
scrollingTimeout = setTimeout(()=>{
isJumping = false;
brewRenderer.removeEventListener('scroll', checkIfScrollComplete);
}, 150); // If 150 ms pass without a brewRenderer scroll event, assume scrolling is done
};
this.throttleBrewMove(currentPos, interimPos, targetPos);
// const hashPage = (page != 1) ? `p${page}` : '';
// window.location.hash = hashPage;
isJumping = true;
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;
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);
} else {
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'instant', block: 'start' });
}
},
sourceJump : function(targetLine=null){
if(this.isText()) {
if(targetLine == null) {
targetLine = 0;
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
if(!this.isText || isJumping)
return;
const pageCollection = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('page');
const brewRendererHeight = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer').item(0).getBoundingClientRect().height;
const textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\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 currentPage = 1;
for (const page of pageCollection) {
if(page.getBoundingClientRect().bottom > (brewRendererHeight / 2)) {
currentPage = parseInt(page.id.slice(1)) || 1;
break;
}
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);
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);
// 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);
// 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 textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\page/;
const textString = this.props.brew.text.split(textSplit).slice(0, currentPage-1).join(textSplit);
const textPosition = textString.length;
const lineCount = textString.match('\n') ? textString.slice(0, textPosition).split('\n').length : 0;
targetLine = lineCount - 1; //Scroll to `\page`, which is one line back.
let currentY = this.codeEditor.current.codeMirror.getScrollInfo().top;
let targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
//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);
// 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);
// 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);
}
}, 10);
}
}, 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');
}
},
@@ -426,7 +500,9 @@ const Editor = createClass({
currentEditorTheme={this.state.editorTheme}
updateEditorTheme={this.updateEditorTheme}
snippetBundle={this.props.snippetBundle}
cursorPos={this.codeEditor.current?.getCursorPosition() || {}} />
cursorPos={this.codeEditor.current?.getCursorPosition() || {}}
updateBrew={this.props.updateBrew}
/>
{this.renderEditor()}
</div>

View File

@@ -5,6 +5,8 @@ const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
import { getHistoryItems, historyExists } from '../../utils/versionHistory.js';
//Import all themes
const ThemeSnippets = {};
ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js');
@@ -38,7 +40,8 @@ const Snippetbar = createClass({
unfoldCode : ()=>{},
updateEditorTheme : ()=>{},
cursorPos : {},
snippetBundle : []
snippetBundle : [],
updateBrew : ()=>{}
};
},
@@ -46,7 +49,8 @@ const Snippetbar = createClass({
return {
renderer : this.props.renderer,
themeSelector : false,
snippets : []
snippets : [],
historyExists : false
};
},
@@ -59,18 +63,20 @@ const Snippetbar = createClass({
componentDidUpdate : async function(prevProps) {
if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) {
const snippets = this.compileSnippets();
this.setState({
snippets : snippets
snippets : this.compileSnippets()
});
}
},
};
this.setState({
historyExists : historyExists(this.props.brew)
});
},
mergeCustomizer : function(oldValue, newValue, key) {
if(key == 'snippets') {
const result = _.reverse(_.unionBy(_.reverse(newValue), _.reverse(oldValue), 'name')); // Join snippets together, with preference for the child theme over the parent theme
return _.filter(result, 'gen'); //Only keep snippets with a 'gen' property.
return result.filter((snip)=>snip.gen || snip.subsnippets);
}
},
@@ -138,6 +144,36 @@ const Snippetbar = createClass({
});
},
replaceContent : function(item){
return this.props.updateBrew(item);
},
renderHistoryItems : function() {
const historyItems = getHistoryItems(this.props.brew);
return <div className='dropdown'>
{_.map(historyItems, (item, index)=>{
if(!item.savedAt) return;
const saveTime = new Date(item.savedAt);
const diffMs = new Date() - saveTime;
const diffSecs = Math.floor(diffMs / 1000);
let diffString = `about ${diffSecs} seconds ago`;
if(diffSecs > 60) diffString = `about ${Math.floor(diffSecs / 60)} minutes ago`;
if(diffSecs > (60 * 60)) diffString = `about ${Math.floor(diffSecs / (60 * 60))} hours ago`;
if(diffSecs > (24 * 60 * 60)) diffString = `about ${Math.floor(diffSecs / (24 * 60 * 60))} days ago`;
if(diffSecs > (7 * 24 * 60 * 60)) diffString = `about ${Math.floor(diffSecs / (7 * 24 * 60 * 60))} weeks ago`;
return <div className='snippet' key={index} onClick={()=>{this.replaceContent(item);}} >
<i className={`fas fa-${index+1}`} />
<span className='name' title={saveTime.toISOString()}>v{item.version} : {diffString}</span>
</div>;
})}
</div>;
},
renderEditorButtons : function(){
if(!this.props.showEditButtons) return;
@@ -158,6 +194,10 @@ const Snippetbar = createClass({
}
return <div className='editors'>
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`} >
<i className='fas fa-clock-rotate-left' />
{this.state.historyExists && this.renderHistoryItems() }
</div>
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
onClick={this.props.undo} >
<i className='fas fa-undo' />

View File

@@ -53,6 +53,21 @@
font-size : 0.75em;
color : inherit;
}
&.history {
.tooltipLeft('History');
font-size : 0.75em;
color : grey;
position : relative;
&.active {
color : inherit;
}
&>.dropdown{
right : -1px;
&>.snippet{
padding-right : 10px;
}
}
}
&.editorTheme {
.tooltipLeft('Editor Themes');
font-size : 0.75em;

View File

@@ -10,6 +10,7 @@ const UserPage = require('./pages/userPage/userPage.jsx');
const SharePage = require('./pages/sharePage/sharePage.jsx');
const NewPage = require('./pages/newPage/newPage.jsx');
const ErrorPage = require('./pages/errorPage/errorPage.jsx');
const VaultPage = require('./pages/vaultPage/vaultPage.jsx');
const AccountPage = require('./pages/accountPage/accountPage.jsx');
const WithRoute = (props)=>{
@@ -71,6 +72,7 @@ const Homebrew = createClass({
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} />
<Route path='/new' element={<WithRoute el={NewPage} userThemes={this.props.userThemes}/> } />
<Route path='/user/:username' element={<WithRoute el={UserPage} brews={this.props.brews} />} />
<Route path='/vault' element={<WithRoute el={VaultPage}/>}/>
<Route path='/changelog' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
<Route path='/faq' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
<Route path='/migrate' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />

View File

@@ -111,7 +111,7 @@ const ErrorNavItem = createClass({
Looks like there was a problem retreiving
the theme, or a theme that it inherits,
for this brew. Verify that brew <a className='lowercase' target='_blank' rel='noopener noreferrer' href={`/share/${response.body.brewId}`}>
{response.body.brewId}</a> still exists!
{response.body.brewId}</a> still exists!
</div>
</Nav.item>;
}

View File

@@ -1,6 +1,7 @@
@import 'naturalcrit/styles/colors.less';
@navbarHeight : 28px;
@viewerToolsHeight : 32px;
@keyframes pinkColoring {
0% { color : pink; }

View File

@@ -0,0 +1,17 @@
const React = require('react');
const Nav = require('naturalcrit/nav/nav.jsx');
module.exports = function (props) {
return (
<Nav.item
color='purple'
icon='fas fa-dungeon'
href='/vault'
newTab={false}
rel='noopener noreferrer'
>
Vault
</Nav.item>
);
};

View File

@@ -19,7 +19,8 @@ const BrewItem = createClass({
stubbed : true
},
updateListFilter : ()=>{},
reportError : ()=>{}
reportError : ()=>{},
renderStorage : true
};
},
@@ -95,6 +96,7 @@ const BrewItem = createClass({
},
renderStorageIcon : function(){
if(!this.props.renderStorage) return;
if(this.props.brew.googleId) {
return <span title={this.props.brew.webViewLink ? 'Your Google Drive Storage': 'Another User\'s Google Drive Storage'}>
<a href={this.props.brew.webViewLink} target='_blank'>
@@ -142,10 +144,14 @@ const BrewItem = createClass({
}
<span title={`Authors:\n${brew.authors?.join('\n')}`}>
<i className='fas fa-user'/> {brew.authors?.map((author, index)=>(
<>
<a key={index} href={`/user/${author}`}>{author}</a>
{index < brew.authors.length - 1 && ', '}
</>))}
<React.Fragment key={index}>
{author === 'hidden'
? <span title="Username contained an email address; hidden to protect user's privacy">{author}</span>
: <a href={`/user/${author}`}>{author}</a>
}
{index < brew.authors.length - 1 && ', '}
</React.Fragment>
))}
</span>
<br />
<span title={`Last viewed: ${moment(brew.lastViewed).local().format(dateFormatString)}`}>

View File

@@ -1,8 +1,9 @@
/* eslint-disable max-lines */
require('./editPage.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const createClass = require('create-react-class');
const request = require('../../utils/request-middleware.js');
const { Meta } = require('vitreum/headtags');
@@ -27,6 +28,8 @@ const Markdown = require('naturalcrit/markdown.js');
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
const googleDriveIcon = require('../../googleDrive.svg');
const SAVE_TIMEOUT = 3000;
@@ -41,22 +44,24 @@ const EditPage = createClass({
getInitialState : function() {
return {
brew : this.props.brew,
isSaving : false,
isPending : 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(),
currentEditorPage : 0,
displayLockMessage : this.props.brew.lock || false,
themeBundle : {}
brew : this.props.brew,
isSaving : false,
isPending : 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 : {}
};
},
@@ -113,16 +118,27 @@ const EditPage = createClass({
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 },
isPending : true,
htmlErrors : htmlErrors,
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
brew : { ...prevState.brew, text: text },
isPending : true,
htmlErrors : htmlErrors,
}), ()=>{if(this.state.autoSave) this.trySave();});
},
@@ -150,6 +166,16 @@ const EditPage = createClass({
return !_.isEqual(this.state.brew, this.savedBrew);
},
updateBrew : function(newData){
this.setState((prevState)=>({
brew : {
...prevState.brew,
style : newData.style,
text : newData.text
}
}));
},
trySave : function(immediate=false){
if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT);
if(this.hasChanges()){
@@ -202,6 +228,9 @@ const EditPage = createClass({
htmlErrors : Markdown.validate(prevState.brew.text)
}));
updateHistory(this.state.brew);
versionHistoryGarbageCollection();
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
const brew = this.state.brew;
@@ -413,6 +442,12 @@ const EditPage = createClass({
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
snippetBundle={this.state.themeBundle.snippets}
updateBrew={this.updateBrew}
onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/>
<BrewRenderer
text={this.state.brew.text}
@@ -422,7 +457,10 @@ const EditPage = createClass({
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors}
lang={this.state.brew.lang}
currentEditorPage={this.state.currentEditorPage}
onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
allowPrint={true}
/>
</SplitPane>

View File

@@ -2,6 +2,9 @@ const dedent = require('dedent-tabs').default;
const loginUrl = 'https://www.naturalcrit.com/login';
//001-050 : Brew errors
//050-100 : Other pages errors
const errorIndex = (props)=>{
return {
// Default catch all
@@ -149,8 +152,16 @@ const errorIndex = (props)=>{
**Brew ID:** ${props.brew.brewId}`,
//account page when account is not defined
'50' : dedent`
## You are not signed in
You are trying to access the account page, but are not signed in to an account.
Please login or signup at our [login page](https://www.naturalcrit.com/login?redirect=https://homebrewery.naturalcrit.com/account).`,
// Brew locked by Administrators error
'100' : dedent`
'51' : dedent`
## This brew has been locked.
Only an author may request that this lock is removed.
@@ -160,7 +171,12 @@ const errorIndex = (props)=>{
**Brew ID:** ${props.brew.brewId}
**Brew Title:** ${props.brew.brewTitle}`,
'90' : dedent` An unexpected error occurred while looking for these brews.
Try again in a few minutes.`,
'91' : dedent` An unexpected error occurred while trying to get the total of brews.`,
};
};
module.exports = errorIndex;
module.exports = errorIndex;

View File

@@ -1,7 +1,6 @@
require('./homePage.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
const request = require('../../utils/request-middleware.js');
const { Meta } = require('vitreum/headtags');
@@ -10,12 +9,12 @@ 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');
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
@@ -32,11 +31,13 @@ const HomePage = createClass({
},
getInitialState : function() {
return {
brew : this.props.brew,
welcomeText : this.props.brew.text,
error : undefined,
currentEditorPage : 0,
themeBundle : {}
brew : this.props.brew,
welcomeText : this.props.brew.text,
error : undefined,
currentEditorViewPageNum : 1,
currentEditorCursorPageNum : 1,
currentBrewRendererPageNum : 1,
themeBundle : {}
};
},
@@ -61,10 +62,22 @@ const HomePage = createClass({
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){
this.setState((prevState)=>({
brew : { ...prevState.brew, text: text },
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
brew : { ...prevState.brew, text: text },
}));
},
renderNavbar : function(){
@@ -76,6 +89,7 @@ const HomePage = createClass({
}
<NewBrewItem />
<HelpNavItem />
<VaultNavItem />
<RecentNavItem />
<AccountNavItem />
</Nav.section>
@@ -96,12 +110,20 @@ const HomePage = createClass({
renderer={this.state.brew.renderer}
showEditButtons={false}
snippetBundle={this.state.themeBundle.snippets}
onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
currentEditorPage={this.state.currentEditorPage}
onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
themeBundle={this.state.themeBundle}
/>
</SplitPane>

View File

@@ -39,13 +39,15 @@ const NewPage = createClass({
const brew = this.props.brew;
return {
brew : brew,
isSaving : false,
saveGoogle : (global.account && global.account.googleId ? true : false),
error : null,
htmlErrors : Markdown.validate(brew.text),
currentEditorPage : 0,
themeBundle : {}
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 : {}
};
},
@@ -84,6 +86,9 @@ const NewPage = createClass({
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') {
window.history.replaceState({}, window.location.title, '/new/');
}
},
componentWillUnmount : function() {
document.removeEventListener('keydown', this.handleControlKeys);
@@ -105,15 +110,26 @@ const NewPage = createClass({
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,
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
brew : { ...prevState.brew, text: text },
htmlErrors : htmlErrors,
}));
localStorage.setItem(BREWKEY, text);
},
@@ -218,6 +234,11 @@ const NewPage = createClass({
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
snippetBundle={this.state.themeBundle.snippets}
onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/>
<BrewRenderer
text={this.state.brew.text}
@@ -227,7 +248,10 @@ const NewPage = createClass({
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors}
lang={this.state.brew.lang}
currentEditorPage={this.state.currentEditorPage}
onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
allowPrint={true}
/>
</SplitPane>

View File

@@ -12,6 +12,7 @@ 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');
const UserPage = createClass({
displayName : 'UserPage',
@@ -66,6 +67,7 @@ const UserPage = createClass({
}
<NewBrew />
<HelpNavItem />
<VaultNavitem/>
<RecentNavItem />
<Account />
</Nav.section>

View File

@@ -0,0 +1,396 @@
require('./vaultPage.less');
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');
const request = require('../../utils/request-middleware.js');
const VaultPage = (props)=>{
const [pageState, setPageState] = useState(parseInt(props.query.page) || 1);
//Response state
const [brewCollection, setBrewCollection] = useState(null);
const [totalBrews, setTotalBrews] = useState(null);
const [searching, setSearching] = useState(false);
const [error, setError] = useState(null);
const titleRef = useRef(null);
const authorRef = useRef(null);
const countRef = useRef(null);
const v3Ref = useRef(null);
const legacyRef = useRef(null);
const submitButtonRef = useRef(null);
useEffect(()=>{
disableSubmitIfFormInvalid();
loadPage(pageState, true);
}, []);
const updateStateWithBrews = (brews, page)=>{
setBrewCollection(brews || null);
setPageState(parseInt(page) || 1);
setSearching(false);
};
const updateUrl = (titleValue, authorValue, countValue, v3Value, legacyValue, page)=>{
const url = new URL(window.location.href);
const urlParams = new URLSearchParams(url.search);
urlParams.set('title', titleValue);
urlParams.set('author', authorValue);
urlParams.set('count', countValue);
urlParams.set('v3', v3Value);
urlParams.set('legacy', legacyValue);
urlParams.set('page', page);
url.search = urlParams.toString();
window.history.replaceState(null, '', url.toString());
};
const performSearch = async (title, author, count, v3, legacy, page)=>{
updateUrl(title, author, count, v3, legacy, page);
const response = await request.get(
`/api/vault?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}&count=${count}&page=${page}`
).catch((error)=>{
console.log('error at loadPage: ', error);
setError(error);
updateStateWithBrews([], 1);
});
if(response.ok)
updateStateWithBrews(response.body.brews, page);
};
const loadTotal = async (title, author, v3, legacy)=>{
setTotalBrews(null);
const response = await request.get(
`/api/vault/total?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}`
).catch((error)=>{
console.log('error at loadTotal: ', error);
setError(error);
updateStateWithBrews([], 1);
});
if(response.ok)
setTotalBrews(response.body.totalBrews);
};
const loadPage = async (page, updateTotal)=>{
if(!validateForm())
return;
setSearching(true);
setError(null);
const title = titleRef.current.value || '';
const author = authorRef.current.value || '';
const count = countRef.current.value || 10;
const v3 = v3Ref.current.checked != false;
const legacy = legacyRef.current.checked != false;
performSearch(title, author, count, v3, legacy, page);
if(updateTotal)
loadTotal(title, author, v3, legacy);
};
const renderNavItems = ()=>(
<Navbar>
<Nav.section>
<Nav.item className='brewTitle'>
Vault: Search for brews
</Nav.item>
</Nav.section>
<Nav.section>
<NewBrew />
<HelpNavItem />
<RecentNavItem />
<Account />
</Nav.section>
</Navbar>
);
const validateForm = ()=>{
//form validity: title or author must be written, and at least one renderer set
const isTitleValid = titleRef.current.validity.valid && titleRef.current.value;
const isAuthorValid = authorRef.current.validity.valid && authorRef.current.value;
const isCheckboxChecked = legacyRef.current.checked || v3Ref.current.checked;
const isFormValid = (isTitleValid || isAuthorValid) && isCheckboxChecked;
return isFormValid;
};
const disableSubmitIfFormInvalid = ()=>{
submitButtonRef.current.disabled = !validateForm();
};
const renderForm = ()=>(
<div className='brewLookup'>
<h2 className='formTitle'>Brew Lookup</h2>
<div className='formContents'>
<label>
Title of the brew
<input
ref={titleRef}
type='text'
name='title'
defaultValue={props.query.title || ''}
onKeyUp={disableSubmitIfFormInvalid}
pattern='.{3,}'
title='At least 3 characters'
onKeyDown={(e)=>{
if(e.key === 'Enter' && !submitButtonRef.current.disabled)
loadPage(1, true);
}}
placeholder='v3 Reference Document'
/>
</label>
<label>
Author of the brew
<input
ref={authorRef}
type='text'
name='author'
pattern='.{1,}'
defaultValue={props.query.author || ''}
onKeyUp={disableSubmitIfFormInvalid}
onKeyDown={(e)=>{
if(e.key === 'Enter' && !submitButtonRef.current.disabled)
loadPage(1, true);
}}
placeholder='Username'
/>
</label>
<label>
Results per page
<select ref={countRef} name='count' defaultValue={props.query.count || 20}>
<option value='10'>10</option>
<option value='20'>20</option>
<option value='40'>40</option>
<option value='60'>60</option>
</select>
</label>
<label>
<input
className='renderer'
ref={v3Ref}
type='checkbox'
defaultChecked={props.query.v3 !== 'false'}
onChange={disableSubmitIfFormInvalid}
/>
Search for v3 brews
</label>
<label>
<input
className='renderer'
ref={legacyRef}
type='checkbox'
defaultChecked={props.query.legacy !== 'false'}
onChange={disableSubmitIfFormInvalid}
/>
Search for legacy brews
</label>
<button
id='searchButton'
ref={submitButtonRef}
onClick={()=>{
loadPage(1, true);
}}
>
Search
<i
className={searching ? 'fas fa-spin fa-spinner': 'fas fa-search'}
/>
</button>
</div>
<legend>
<h3>Tips and tricks</h3>
<ul>
<li>
Only <b>published</b> brews are searchable via this tool
</li>
<li>
Usernames are case-sensitive
</li>
<li>
Use <code>"word"</code> to match an exact string,
and <code>-</code> to exclude words (at least one word must not be negated)
</li>
<li>
Some common words like "a", "after", "through", "itself", "here", etc.,
are ignored in searches. The full list can be found &nbsp;
<a href='https://github.com/mongodb/mongo/blob/0e3b3ca8480ddddf5d0105d11a94bd4698335312/src/mongo/db/fts/stop_words_english.txt'>
here
</a>
</li>
</ul>
<small>New features will be coming, such as filters and search by tags.</small>
</legend>
</div>
);
const renderPaginationControls = ()=>{
if(!totalBrews) return null;
const countInt = parseInt(props.query.count || 20);
const totalPages = Math.ceil(totalBrews / countInt);
let startPage, endPage;
if(pageState <= 6) {
startPage = 1;
endPage = Math.min(totalPages, 10);
} else if(pageState + 4 >= totalPages) {
startPage = Math.max(1, totalPages - 9);
endPage = totalPages;
} else {
startPage = pageState - 5;
endPage = pageState + 4;
}
const pagesAroundCurrent = new Array(endPage - startPage + 1)
.fill()
.map((_, index)=>(
<a
key={startPage + index}
className={`pageNumber ${
pageState === startPage + index ? 'currentPage' : ''
}`}
onClick={()=>loadPage(startPage + index, false)}
>
{startPage + index}
</a>
));
return (
<div className='paginationControls'>
<button
className='previousPage'
onClick={()=>loadPage(pageState - 1, false)}
disabled={pageState === startPage}
>
<i className='fa-solid fa-chevron-left'></i>
</button>
<ol className='pages'>
{startPage > 1 && (
<a
className='pageNumber firstPage'
onClick={()=>loadPage(1, false)}
>
1 ...
</a>
)}
{pagesAroundCurrent}
{endPage < totalPages && (
<a
className='pageNumber lastPage'
onClick={()=>loadPage(totalPages, false)}
>
... {totalPages}
</a>
)}
</ol>
<button
className='nextPage'
onClick={()=>loadPage(pageState + 1, false)}
disabled={pageState === totalPages}
>
<i className='fa-solid fa-chevron-right'></i>
</button>
</div>
);
};
const renderFoundBrews = ()=>{
if(searching) {
return (
<div className='foundBrews searching'>
<h3 className='searchAnim'>Searching</h3>
</div>
);
}
if(error) {
const errorText = ErrorIndex()[error.HBErrorCode.toString()] || '';
return (
<div className='foundBrews noBrews'>
<h3>Error: {errorText}</h3>
</div>
);
}
if(!brewCollection) {
return (
<div className='foundBrews noBrews'>
<h3>No search yet</h3>
</div>
);
}
if(brewCollection.length === 0) {
return (
<div className='foundBrews noBrews'>
<h3>No brews found</h3>
</div>
);
}
return (
<div className='foundBrews'>
<span className='totalBrews'>
{`Brews found: `}
<span>{totalBrews}</span>
</span>
{brewCollection.map((brew, index)=>{
return (
<BrewItem
brew={{ ...brew }}
key={index}
reportError={props.reportError}
renderStorage={false}
/>
);
})}
{renderPaginationControls()}
</div>
);
};
return (
<div className='vaultPage'>
<link href='/themes/V3/Blank/style.css' rel='stylesheet' />
<link href='/themes/V3/5ePHB/style.css' rel='stylesheet' />
{renderNavItems()}
<div className='content'>
<SplitPane showDividerButtons={false}>
<div className='form dataGroup'>{renderForm()}</div>
<div className='resultsContainer dataGroup'>
{renderFoundBrews()}
</div>
</SplitPane>
</div>
</div>
);
};
module.exports = VaultPage;

View File

@@ -0,0 +1,362 @@
.vaultPage {
height : 100%;
overflow-y : hidden;
background-color : #2C3E50;
*:not(input) { user-select : none; }
.content {
background : #2C3E50;
height: 100%;
.dataGroup {
width : 100%;
height : 100%;
background : white;
&.form .brewLookup {
position : relative;
padding : 50px clamp(20px, 4vw, 50px);
small {
font-size : 10pt;
color : #555555;
a { color : #333333; }
}
code {
padding-inline : 5px;
background : lightgrey;
border-radius : 5px;
font-family : monospace;
}
h1, h2, h3, h4 {
font-family : 'CodeBold';
letter-spacing : 2px;
}
legend {
h3 {
margin-block : 30px 20px;
font-size : 20px;
text-align : center;
border-bottom : 2px solid;
}
ul {
padding-inline : 30px 10px;
li {
margin-block : 5px;
line-height : calc(1em + 5px);
list-style : disc;
}
}
}
&::after {
position : absolute;
top : 0;
right : 0;
left : 0;
display : block;
padding : 10px;
font-weight : 900;
color : white;
white-space : pre-wrap;
content : 'Error:\A At least one renderer should be enabled to make a search';
background : rgb(255, 60, 60);
opacity : 0;
transition : opacity 0.5s;
}
&:not(:has(input[type='checkbox']:checked))::after { opacity : 1; }
.formTitle {
margin : 20px 0;
font-size : 30px;
color : black;
text-align : center;
border-bottom : 2px solid;
}
.formContents {
position : relative;
display : flex;
flex-direction : column;
label {
display : flex;
align-items : center;
margin : 10px 0;
}
select { margin : 0 10px; }
input {
margin : 0 10px;
&:invalid { background : rgb(255, 188, 181); }
&[type='checkbox'] {
position : relative;
display : inline-block;
width : 50px;
height : 30px;
font-family : 'WalterTurncoat';
font-size : 20px;
font-weight : 800;
color : white;
letter-spacing : 2px;
appearance : none;
background : red;
isolation : isolate;
border-radius : 5px;
&::before,&::after {
position : absolute;
inset : 0;
z-index : 5;
padding-top : 2px;
text-align : center;
}
&::before {
display : block;
content : 'No';
}
&::after {
display : none;
content : 'Yes';
}
&:checked {
background : green;
&::before { display : none; }
&::after { display : block; }
}
}
}
#searchButton {
position : absolute;
right : 20px;
bottom : 0;
i {
margin-left : 10px;
animation-duration : 1000s;
}
}
}
}
&.resultsContainer {
display : flex;
flex-direction : column;
height : 100%;
overflow-y : auto;
font-family : 'BookInsanityRemake';
font-size : 0.34cm;
h3 {
font-family : 'Open Sans';
font-weight : 900;
color : white;
}
.foundBrews {
position : relative;
width : 100%;
height : 100%;
max-height : 100%;
padding : 50px 50px 70px 50px;
overflow-y : scroll;
background-color : #2C3E50;
h3 { font-size : 25px; }
&.noBrews {
display : grid;
place-items : center;
color : white;
}
&.searching {
display : grid;
place-items : center;
color : white;
h3 { position : relative; }
h3.searchAnim::after {
position : absolute;
top : 50%;
right : 0;
width : max-content;
height : 1em;
content : '';
translate : calc(100% + 5px) -50%;
animation : trailingDots 2s ease infinite;
}
}
.totalBrews {
position : fixed;
right : 0;
bottom : 0;
z-index : 1000;
padding : 8px 10px;
font-family : 'Open Sans';
font-size : 11px;
font-weight : 800;
color : white;
background-color : #333333;
.searchAnim {
position : relative;
display : inline-block;
width : 3ch;
height : 1em;
}
.searchAnim::after {
position : absolute;
top : 50%;
right : 0;
width : max-content;
height : 1em;
content : '';
translate : -50% -50%;
animation : trailingDots 2s ease infinite;
}
}
.brewItem {
width : 47%;
margin-right : 40px;
color : black;
isolation:isolate;
&:after {
position:absolute;
inset:0;
display:block;
content:'';
background-image : url('/assets/parchmentBackground.jpg');
z-index:-1;
}
&:nth-child(even of .brewItem) { margin-right : 0; }
h2 {
font-family : 'MrEavesRemake';
font-size : 0.75cm;
font-weight : 800;
line-height : 0.988em;
color : var(--HB_Color_HeaderText);
}
.info {
font-family : 'ScalySansRemake';
font-size : 1.2em;
position:relative;
z-index:2;
>span {
margin-right : 12px;
line-height : 1.5em;
}
}
.links {
z-index:2;
}
hr {
margin: 0px;
visibility: hidden;
}
.thumbnail {
z-index:1;
}
}
.paginationControls {
position : absolute;
left : 50%;
display : grid;
grid-template-areas : 'previousPage currentPage nextPage';
grid-template-columns : 50px 1fr 50px;
place-items : center;
width : auto;
translate : -50%;
.pages {
display : flex;
grid-area : currentPage;
justify-content : space-evenly;
width : 100%;
height : 100%;
padding : 5px 8px;
text-align : center;
.pageNumber {
margin-inline : 1vw;
font-family : 'Open Sans';
font-weight : 900;
color : white;
text-underline-position : under;
text-wrap : nowrap;
cursor : pointer;
&.currentPage {
color : gold;
text-decoration : underline;
pointer-events : none;
}
&.firstPage { margin-right : -5px; }
&.lastPage { margin-left : -5px; }
}
}
button {
width : max-content;
&.previousPage { grid-area : previousPage; }
&.nextPage { grid-area : nextPage; }
}
}
}
}
}
}
}
@keyframes trailingDots {
0%,
32% { content : ' .'; }
33%,
65% { content : ' ..'; }
66%,
100% { content : ' ...'; }
}
// media query for when the page is smaller than 1079 px in width
@media screen and (max-width : 1079px) {
.vaultPage .content {
.dataGroup.form .brewLookup { padding : 1px 20px 20px 10px; }
.dataGroup.resultsContainer .foundBrews .brewItem {
width : 100%;
margin-inline : auto;
}
}
}

View File

@@ -0,0 +1,116 @@
export const HISTORY_PREFIX = 'HOMEBREWERY-HISTORY';
export const HISTORY_SLOTS = 5;
// History values in minutes
const DEFAULT_HISTORY_SAVE_DELAYS = {
'0' : 0,
'1' : 2,
'2' : 10,
'3' : 60,
'4' : 12 * 60,
'5' : 2 * 24 * 60
};
const DEFAULT_GARBAGE_COLLECT_DELAY = 28 * 24 * 60;
const HISTORY_SAVE_DELAYS = global.config?.historyData?.HISTORY_SAVE_DELAYS ?? DEFAULT_HISTORY_SAVE_DELAYS;
const GARBAGE_COLLECT_DELAY = global.config?.historyData?.GARBAGE_COLLECT_DELAY ?? DEFAULT_GARBAGE_COLLECT_DELAY;
function getKeyBySlot(brew, slot){
return `${HISTORY_PREFIX}-${brew.shareId}-${slot}`;
};
function getVersionBySlot(brew, slot){
// Read stored brew data
// - If it exists, parse data to object
// - If it doesn't exist, pass default object
const key = getKeyBySlot(brew, slot);
const storedVersion = localStorage.getItem(key);
const output = storedVersion ? JSON.parse(storedVersion) : { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true };
return output;
};
function updateStoredBrew(brew, slot = 0) {
const archiveBrew = {
title : brew.title,
text : brew.text,
style : brew.style,
version : brew.version,
shareId : brew.shareId,
savedAt : brew?.savedAt || new Date(),
expireAt : new Date()
};
archiveBrew.expireAt.setMinutes(archiveBrew.expireAt.getMinutes() + HISTORY_SAVE_DELAYS[slot]);
const key = getKeyBySlot(brew, slot);
localStorage.setItem(key, JSON.stringify(archiveBrew));
}
export function historyExists(brew){
return Object.keys(localStorage)
.some((key)=>{
return key.startsWith(`${HISTORY_PREFIX}-${brew.shareId}`);
});
}
export function loadHistory(brew){
const history = {};
// Load data from local storage to History object
for (let i = 1; i <= HISTORY_SLOTS; i++){
history[i] = getVersionBySlot(brew, i);
};
return history;
}
export function updateHistory(brew) {
const history = loadHistory(brew);
// Walk each version position
for (let slot = HISTORY_SLOTS; slot > 0; slot--){
const storedVersion = history[slot];
// If slot has expired, update all lower slots and break
if(new Date() >= new Date(storedVersion.expireAt)){
for (let updateSlot = slot - 1; updateSlot>0; updateSlot--){
// Move data from updateSlot to updateSlot + 1
!history[updateSlot]?.noData && updateStoredBrew(history[updateSlot], updateSlot + 1);
};
// Update the most recent brew
updateStoredBrew(brew, 1);
// Break out of data checks because we found an expired value
break;
}
};
};
export function getHistoryItems(brew){
const historyArray = [];
for (let i = 1; i <= HISTORY_SLOTS; i++){
historyArray.push(getVersionBySlot(brew, i));
}
return historyArray;
};
export function versionHistoryGarbageCollection(){
Object.keys(localStorage)
.filter((key)=>{
return key.startsWith(HISTORY_PREFIX);
})
.forEach((key)=>{
const collectAt = new Date(JSON.parse(localStorage.getItem(key)).savedAt);
collectAt.setMinutes(collectAt.getMinutes() + GARBAGE_COLLECT_DELAY);
if(new Date() > collectAt){
localStorage.removeItem(key);
}
});
};

View File

@@ -1,57 +1,75 @@
.fac {
display : inline-block;
display : inline-block;
background-color : currentColor;
mask-size : contain;
mask-repeat : no-repeat;
mask-position : center;
width : 1em;
aspect-ratio : 1;
}
.position-top-left {
content: url('../icons/position-top-left.svg');
mask-image: url('../icons/position-top-left.svg');
}
.position-top-right {
content: url('../icons/position-top-right.svg');
mask-image: url('../icons/position-top-right.svg');
}
.position-bottom-left {
content: url('../icons/position-bottom-left.svg');
mask-image: url('../icons/position-bottom-left.svg');
}
.position-bottom-right {
content: url('../icons/position-bottom-right.svg');
mask-image: url('../icons/position-bottom-right.svg');
}
.position-top {
content: url('../icons/position-top.svg');
mask-image: url('../icons/position-top.svg');
}
.position-right {
content: url('../icons/position-right.svg');
mask-image: url('../icons/position-right.svg');
}
.position-bottom {
content: url('../icons/position-bottom.svg');
mask-image: url('../icons/position-bottom.svg');
}
.position-left {
content: url('../icons/position-left.svg');
mask-image: url('../icons/position-left.svg');
}
.mask-edge {
content: url('../icons/mask-edge.svg');
mask-image: url('../icons/mask-edge.svg');
}
.mask-corner {
content: url('../icons/mask-corner.svg');
mask-image: url('../icons/mask-corner.svg');
}
.mask-center {
content: url('../icons/mask-center.svg');
mask-image: url('../icons/mask-center.svg');
}
.book-front-cover {
content: url('../icons/book-front-cover.svg');
mask-image: url('../icons/book-front-cover.svg');
}
.book-back-cover {
content: url('../icons/book-back-cover.svg');
mask-image: url('../icons/book-back-cover.svg');
}
.book-inside-cover {
content: url('../icons/book-inside-cover.svg');
mask-image: url('../icons/book-inside-cover.svg');
}
.book-part-cover {
content: url('../icons/book-part-cover.svg');
mask-image: url('../icons/book-part-cover.svg');
}
.image-wrap-left {
mask-image: url('../icons/image-wrap-left.svg');
}
.image-wrap-right {
mask-image: url('../icons/image-wrap-right.svg');
}
.davek {
content: url('../icons/Davek.svg');
mask-image: url('../icons/Davek.svg');
}
.rellanic {
content: url('../icons/Rellanic.svg');
mask-image: url('../icons/Rellanic.svg');
}
.iokharic {
content: url('../icons/Iokharic.svg');
mask-image: url('../icons/Iokharic.svg');
}
.zoom-to-fit {
mask-image: url('../icons/zoom-to-fit.svg');
}
.fit-width {
mask-image: url('../icons/fit-width.svg');
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.07509,0,0,1.07509,-3.75511,-3.75468)">
<g transform="matrix(0.843549,0,0,0.950644,8.38004,4.39672)">
<path d="M28.455,52.413L28.455,58.581C28.455,59.719 27.684,60.745 26.501,61.181C25.318,61.616 23.956,61.375 23.051,60.571L11.114,49.96C9.878,48.862 9.878,47.08 11.114,45.981L23.051,35.371C23.956,34.566 25.318,34.326 26.501,34.761C27.684,35.197 28.455,36.223 28.455,37.361L28.455,43.528L70.223,43.528L70.223,37.361C70.223,36.223 70.995,35.197 72.177,34.761C73.36,34.326 74.722,34.566 75.627,35.371L87.564,45.981C88.8,47.08 88.8,48.862 87.564,49.96L75.627,60.571C74.722,61.375 73.36,61.616 72.177,61.181C70.995,60.745 70.223,59.719 70.223,58.581L70.223,52.413L28.455,52.413Z"/>
</g>
<g transform="matrix(1.46702,0,0,0.986488,-23.0335,3.50686)">
<path d="M23.967,5.877L23.967,88.383C23.967,90.556 22.781,92.321 21.319,92.321L21.157,92.321C19.695,92.321 18.509,90.556 18.509,88.383L18.509,5.877C18.509,3.703 19.695,1.939 21.157,1.939L21.319,1.939C22.781,1.939 23.967,3.703 23.967,5.877Z"/>
</g>
<g transform="matrix(1.46702,0,0,0.986488,60.7211,3.50686)">
<path d="M23.967,5.877L23.967,88.383C23.967,90.556 22.781,92.321 21.319,92.321L21.157,92.321C19.695,92.321 18.509,90.556 18.509,88.383L18.509,5.877C18.509,3.703 19.695,1.939 21.157,1.939L21.319,1.939C22.781,1.939 23.967,3.703 23.967,5.877Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
x="0px"
y="0px"
viewBox="0 0 512.00006 512"
xml:space="preserve"
id="svg10"
sodipodi:docname="noun-wrap-image-left-212078.svg"
width="512.00006"
height="512"
inkscape:export-filename="image-wrap-right.svg"
inkscape:export-xdpi="300"
inkscape:export-ydpi="300"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs10" /><sodipodi:namedview
id="namedview10"
pagecolor="#ffffff"
bordercolor="#111111"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#d1d1d1" /><path
style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 185.80018,144 H 32"
id="path11"
sodipodi:nodetypes="cc"
clip-path="none"
inkscape:export-filename="image-wrap-right.svg"
inkscape:export-xdpi="300"
inkscape:export-ydpi="300" /><path
style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 185.80018,368 H 32"
id="path11-8"
sodipodi:nodetypes="cc"
clip-path="none" /><path
style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 480.00007,32 H 32"
id="path11-8-2-67"
clip-path="none"
sodipodi:nodetypes="cc" /><path
style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 480.00008,480 H 32"
id="path11-8-2-67-2"
clip-path="none"
sodipodi:nodetypes="cc" /><path
style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 160.0001,255.98832 32,256.01162"
id="path11-0"
sodipodi:nodetypes="cc"
clip-path="none" /><path
id="path23"
style="opacity:0.922046;fill:#000000;fill-opacity:1;stroke-width:64;stroke-linecap:round;stroke-dasharray:none;paint-order:fill markers stroke"
d="m 416.00008,96 a 160,160 0 0 1 96,32.50977 v 254.98046 a 160,160 0 0 1 -96,32.50977 160,160 0 0 1 -160,-160 160,160 0 0 1 160,-160 z" /></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
x="0px"
y="0px"
viewBox="0 0 512.00006 512"
xml:space="preserve"
id="svg10"
sodipodi:docname="noun-wrap-image-left-212078.svg"
width="512.00006"
height="512"
inkscape:export-filename="image-wrap-right.svg"
inkscape:export-xdpi="300"
inkscape:export-ydpi="300"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs10" /><sodipodi:namedview
id="namedview10"
pagecolor="#ffffff"
bordercolor="#111111"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#d1d1d1" /><path
style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 326.1999,144 H 480.00008"
id="path11"
sodipodi:nodetypes="cc"
clip-path="none"
inkscape:export-filename="image-wrap-right.svg"
inkscape:export-xdpi="300"
inkscape:export-ydpi="300" /><path
style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 326.1999,368 H 480.00008"
id="path11-8"
sodipodi:nodetypes="cc"
clip-path="none" /><path
style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 32.00001,32 H 480.00008"
id="path11-8-2-67"
clip-path="none"
sodipodi:nodetypes="cc" /><path
style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 32,480 H 480.00008"
id="path11-8-2-67-2"
clip-path="none"
sodipodi:nodetypes="cc" /><path
style="fill:none;stroke:#000000;stroke-width:64;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 351.99998,255.98832 128.0001,0.0233"
id="path11-0"
sodipodi:nodetypes="cc"
clip-path="none" /><path
id="path23"
style="opacity:0.922046;fill:#000000;fill-opacity:1;stroke-width:64;stroke-linecap:round;stroke-dasharray:none;paint-order:fill markers stroke"
d="M 96,96 A 160,160 0 0 0 0,128.50977 V 383.49023 A 160,160 0 0 0 96,416 160,160 0 0 0 256,256 160,160 0 0 0 96,96 Z" /></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.0781,0,0,1.0781,-3.90545,-3.90502)">
<g transform="matrix(0.841196,0,0,0.947993,8.49652,4.52391)">
<path d="M44.333,52.413L28.455,52.413L28.455,58.581C28.455,59.719 27.684,60.745 26.501,61.181C25.318,61.616 23.956,61.375 23.051,60.571L11.114,49.96C9.878,48.862 9.878,47.08 11.114,45.981L23.051,35.371C23.956,34.566 25.318,34.326 26.501,34.761C27.684,35.197 28.455,36.223 28.455,37.361L28.455,43.528L44.333,43.528L44.333,29.439L37.382,29.439C36.099,29.439 34.943,28.755 34.452,27.705C33.961,26.656 34.233,25.448 35.14,24.644L47.097,14.052C48.335,12.956 50.343,12.956 51.581,14.052L63.539,24.644C64.446,25.448 64.717,26.656 64.226,27.705C63.735,28.755 62.579,29.439 61.296,29.439L54.346,29.439L54.346,43.528L70.223,43.528L70.223,37.361C70.223,36.223 70.995,35.197 72.177,34.761C73.36,34.326 74.722,34.566 75.627,35.371L87.564,45.981C88.8,47.08 88.8,48.862 87.564,49.96L75.627,60.571C74.722,61.375 73.36,61.616 72.177,61.181C70.995,60.745 70.223,59.719 70.223,58.581L70.223,52.413L54.346,52.413L54.346,66.502L61.296,66.502C62.579,66.502 63.735,67.187 64.226,68.236C64.717,69.286 64.446,70.494 63.539,71.297L51.581,81.889C50.343,82.986 48.335,82.986 47.097,81.889L35.14,71.297C34.233,70.494 33.961,69.286 34.452,68.236C34.943,67.187 36.099,66.502 37.382,66.502L44.333,66.503L44.333,52.413Z"/>
</g>
<g transform="matrix(1.0247,0,0,1.0247,-5.47698,-3.53855)">
<path d="M99.4,14.269L99.4,90.227C99.4,94.245 96.137,97.508 92.119,97.508L16.161,97.508C12.142,97.508 8.88,94.245 8.88,90.227L8.88,14.269C8.88,10.25 12.142,6.988 16.161,6.988L92.119,6.988C96.137,6.988 99.4,10.25 99.4,14.269ZM93.633,14.269C93.633,13.433 92.955,12.755 92.119,12.755L16.161,12.755C15.325,12.755 14.647,13.433 14.647,14.269L14.647,90.227C14.647,91.062 15.325,91.741 16.161,91.741L92.119,91.741C92.955,91.741 93.633,91.062 93.633,90.227L93.633,14.269Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

71
eslint.config.mjs Normal file
View File

@@ -0,0 +1,71 @@
import react from "eslint-plugin-react";
import jest from "eslint-plugin-jest";
import globals from "globals";
export default [{
ignores: ["build/"]
},
{
files : ['**/*.js', '**/*.jsx'],
plugins : { react, jest },
languageOptions : {
ecmaVersion : "latest",
sourceType : "module",
parserOptions : { ecmaFeatures: { jsx: true } },
globals : { ...globals.browser, ...globals.node }
},
rules: {
/** Errors **/
"camelcase" : ["error", { properties: "never" }],
"no-array-constructor" : "error",
"no-iterator" : "error",
"no-nested-ternary" : "error",
"no-new-object" : "error",
"no-proto" : "error",
"react/jsx-no-bind" : ["error", { allowArrowFunctions: true }],
"react/jsx-uses-react" : "error",
"react/prefer-es6-class" : ["error", "never"],
"jest/valid-expect" : ["error", { maxArgs: 3 }],
/** Warnings **/
"max-lines" : ["warn", { max: 200, skipComments: true, skipBlankLines: true }],
"max-depth" : ["warn", { max: 4 }],
"max-params" : ["warn", { max: 5 }],
"no-restricted-syntax" : ["warn", "ClassDeclaration", "SwitchStatement"],
"no-unused-vars" : ["warn", { vars: "all", args: "none", varsIgnorePattern: "config|_|cx|createClass" }],
"react/jsx-uses-vars" : "warn",
/** Fixable **/
"arrow-parens" : ["warn", "always"],
"brace-style" : ["warn", "1tbs", { allowSingleLine: true }],
"jsx-quotes" : ["warn", "prefer-single"],
"no-var" : "warn",
"prefer-const" : "warn",
"prefer-template" : "warn",
"quotes" : ["warn", "single", { allowTemplateLiterals: true }],
"semi" : ["warn", "always"],
/** Whitespace **/
"array-bracket-spacing" : ["warn", "never"],
"arrow-spacing" : ["warn", { before: false, after: false }],
"comma-spacing" : ["warn", { before: false, after: true }],
"indent" : ["warn", "tab", { MemberExpression: "off" }],
"linebreak-style" : "off",
"no-trailing-spaces" : "warn",
"no-whitespace-before-property" : "warn",
"object-curly-spacing" : ["warn", "always"],
"react/jsx-indent-props" : ["warn", "tab"],
"space-in-parens" : ["warn", "never"],
"template-curly-spacing" : ["warn", "never"],
"keyword-spacing" : ["warn", {
before : true,
after : true,
overrides : { if: { before: false, after: false } }
}],
"key-spacing" : ["warn", {
multiLine : { beforeColon: true, afterColon: true, align: "colon" },
singleLine : { beforeColon: false, afterColon: true }
}]
}
}
];

30812
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.14.1",
"version": "3.15.0",
"engines": {
"npm": "^10.2.x",
"node": "^20.8.x"
@@ -15,8 +15,8 @@
"quick": "node scripts/quick.js",
"build": "node scripts/buildHomebrew.js && node scripts/buildAdmin.js",
"builddev": "node scripts/buildHomebrew.js --dev",
"lint": "eslint --fix **/*.{js,jsx}",
"lint:dry": "eslint **/*.{js,jsx}",
"lint": "eslint --fix",
"lint:dry": "eslint",
"stylelint": "stylelint --fix **/*.{less}",
"stylelint:dry": "stylelint **/*.less",
"circleci": "npm test && eslint **/*.{js,jsx} --max-warnings=0",
@@ -24,6 +24,7 @@
"test": "jest --runInBand",
"test:api-unit": "jest \"server/.*.spec.js\" --verbose",
"test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose",
"test:api-unit:css": "jest \"server/.*.spec.js\" -t \"Get CSS\" --verbose",
"test:coverage": "jest --coverage --silent --runInBand",
"test:dev": "jest --verbose --watch",
"test:basic": "jest tests/markdown/basic.test.js --verbose",
@@ -33,6 +34,7 @@
"test:mustache-syntax:block": "jest \".*(mustache-syntax).*\" -t '^Block:.*' --verbose --noStackTrace",
"test:mustache-syntax:injection": "jest \".*(mustache-syntax).*\" -t '^Injection:.*' --verbose --noStackTrace",
"test:definition-lists": "jest tests/markdown/definition-lists.test.js --verbose --noStackTrace",
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
"test:route": "jest tests/routes/static-pages.test.js --verbose",
"phb": "node scripts/phb.js",
@@ -84,10 +86,10 @@
},
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/plugin-transform-runtime": "^7.24.7",
"@babel/preset-env": "^7.25.3",
"@babel/plugin-transform-runtime": "^7.25.4",
"@babel/preset-env": "^7.25.4",
"@babel/preset-react": "^7.24.7",
"@googleapis/drive": "^8.11.0",
"@googleapis/drive": "^8.14.0",
"body-parser": "^1.20.2",
"classnames": "^2.5.1",
"codemirror": "^5.65.6",
@@ -96,43 +98,44 @@
"dedent-tabs": "^0.10.3",
"dompurify": "^3.1.6",
"expr-eval": "^2.0.2",
"express": "^4.19.2",
"express": "^4.21.0",
"express-async-handler": "^1.2.0",
"express-static-gzip": "2.1.7",
"express-static-gzip": "2.1.8",
"fs-extra": "11.2.0",
"js-yaml": "^4.1.0",
"jwt-simple": "^0.5.6",
"less": "^4.2.0",
"less": "^3.13.1",
"lodash": "^4.17.21",
"marked": "11.2.0",
"marked-emoji": "^1.4.2",
"marked-extended-tables": "^1.0.8",
"marked-extended-tables": "^1.0.10",
"marked-gfm-heading-id": "^3.2.0",
"marked-smartypants-lite": "^1.0.2",
"markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1",
"mongoose": "^8.5.2",
"mongoose": "^8.6.2",
"nanoid": "3.3.4",
"nconf": "^0.12.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-frame-component": "^4.1.3",
"react-router-dom": "6.26.0",
"react-router-dom": "6.26.2",
"sanitize-filename": "1.6.3",
"superagent": "^9.0.2",
"superagent": "^10.1.0",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
},
"devDependencies": {
"@stylistic/stylelint-plugin": "^3.0.0",
"eslint": "^8.57.0",
"eslint-plugin-jest": "^28.8.0",
"eslint-plugin-react": "^7.35.0",
"@stylistic/stylelint-plugin": "^3.0.1",
"eslint": "^9.10.0",
"eslint-plugin-jest": "^28.8.3",
"eslint-plugin-react": "^7.36.1",
"globals": "^15.9.0",
"jest": "^29.7.0",
"jest-expect-message": "^1.1.3",
"postcss-less": "^6.0.0",
"stylelint": "^16.8.0",
"stylelint-config-recess-order": "^5.0.1",
"stylelint": "^16.9.0",
"stylelint-config-recess-order": "^5.1.0",
"stylelint-config-recommended": "^14.0.1",
"supertest": "^7.0.0"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -99,7 +99,7 @@ const api = {
stub = stub?.toObject();
if(stub?.lock?.locked && accessType != 'edit') {
throw { HBErrorCode: '100', code: stub.lock.code, message: stub.lock.shareMessage, brewId: stub.shareId, brewTitle: stub.title };
throw { HBErrorCode: '51', code: stub.lock.code, message: stub.lock.shareMessage, brewId: stub.shareId, brewTitle: stub.title };
}
// If there is a google id, try to find the google brew
@@ -148,6 +148,20 @@ const api = {
next();
};
},
getCSS : async (req, res)=>{
const { brew } = req;
if(!brew) return res.status(404).send('');
splitTextStyleAndMetadata(brew);
if(!brew.style) return res.status(404).send('');
res.set({
'Cache-Control' : 'no-cache',
'Content-Type' : 'text/css'
});
return res.status(200).send(brew.style);
},
mergeBrewText : (brew)=>{
let text = brew.text;
if(brew.style !== undefined) {

View File

@@ -50,6 +50,7 @@ describe('Tests for api', ()=>{
res = {
status : jest.fn(()=>res),
send : jest.fn(()=>{}),
set : jest.fn(()=>{}),
setHeader : jest.fn(()=>{})
};
@@ -308,7 +309,7 @@ describe('Tests for api', ()=>{
const req = { brew: {} };
const next = jest.fn();
await expect(fn(req, null, next)).rejects.toEqual({ 'HBErrorCode': '100', 'brewId': '1', 'brewTitle': 'test brew', 'code': 404, 'message': 'brew locked' });
await expect(fn(req, null, next)).rejects.toEqual({ 'HBErrorCode': '51', 'brewId': '1', 'brewTitle': 'test brew', 'code': 404, 'message': 'brew locked' });
});
});
@@ -916,4 +917,66 @@ brew`);
expect(saved.googleId).toEqual(brew.googleId);
});
});
describe('Get CSS', ()=>{
it('should return brew style content as CSS text', async ()=>{
const testBrew = { title: 'test brew', text: '```css\n\nI Have a style!\n````\n\n' };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style', '\nI Have a style!\n');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith('\nI Have a style!\n');
expect(res.set).toHaveBeenCalledWith({
'Cache-Control' : 'no-cache',
'Content-Type' : 'text/css'
});
});
it('should return 404 when brew has no style content', async ()=>{
const testBrew = { title: 'test brew', text: 'I don\'t have a style!' };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith('');
});
it('should return 404 when brew does not exist', async ()=>{
const testBrew = { };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith('');
});
});
});

102
server/vault.api.js Normal file
View File

@@ -0,0 +1,102 @@
const express = require('express');
const asyncHandler = require('express-async-handler');
const HomebrewModel = require('./homebrew.model.js').model;
const router = express.Router();
const titleConditions = (title)=>{
if(!title) return {};
return {
$text : {
$search : title,
$caseSensitive : false,
},
};
};
const authorConditions = (author)=>{
if(!author) return {};
return { authors: author };
};
const rendererConditions = (legacy, v3)=>{
if(legacy === 'true' && v3 !== 'true')
return { renderer: 'legacy' };
if(v3 === 'true' && legacy !== 'true')
return { renderer: 'V3' };
return {}; // If all renderers selected, renderer field not needed in query for speed
};
const findBrews = async (req, res)=>{
const title = req.query.title || '';
const author = req.query.author || '';
const page = Math.max(parseInt(req.query.page) || 1, 1);
const count = Math.max(parseInt(req.query.count) || 20, 10);
const skip = (page - 1) * count;
const combinedQuery = {
$and : [
{ published: true },
rendererConditions(req.query.legacy, req.query.v3),
titleConditions(title),
authorConditions(author)
],
};
const projection = {
editId : 0,
googleId : 0,
text : 0,
textBin : 0,
version : 0
};
await HomebrewModel.find(combinedQuery, projection)
.skip(skip)
.limit(count)
.maxTimeMS(5000)
.exec()
.then((brews)=>{
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const processedBrews = brews.map((brew)=>{
brew.authors = brew.authors.map((author)=>emailRegex.test(author) ? 'hidden' : author
);
return brew;
});
res.json({ brews: processedBrews, page });
})
.catch((error)=>{
throw { ...error, message: 'Error finding brews in Vault search', HBErrorCode: 90 };
});
};
const findTotal = async (req, res)=>{
const title = req.query.title || '';
const author = req.query.author || '';
const combinedQuery = {
$and : [
{ published: true },
rendererConditions(req.query.legacy, req.query.v3),
titleConditions(title),
authorConditions(author)
],
};
await HomebrewModel.countDocuments(combinedQuery)
.then((totalBrews)=>{
console.log(`when returning, the total of brews is ${totalBrews} for the query ${JSON.stringify(combinedQuery)}`);
res.json({ totalBrews });
})
.catch((error)=>{
throw { ...error, message: 'Error finding brews in Vault search findTotal function', HBErrorCode: 91 };
});
};
router.get('/api/vault/total', asyncHandler(findTotal));
router.get('/api/vault', asyncHandler(findBrews));
module.exports = router;

View File

@@ -35,6 +35,7 @@ const printCurrentBrew = ()=>{
};
const fetchThemeBundle = async (obj, renderer, theme)=>{
if(!renderer || !theme) return;
const res = await request
.get(`/api/theme/${renderer}/${theme}`)
.catch((err)=>{

View File

@@ -397,6 +397,11 @@ const CodeEditor = createClass({
getCursorPosition : function(){
return this.codeMirror.getCursor();
},
getTopVisibleLine : function(){
const rect = this.codeMirror.getWrapperElement().getBoundingClientRect();
const topVisibleLine = this.codeMirror.lineAtHeight(rect.top, 'window');
return topVisibleLine;
},
updateSize : function(){
this.codeMirror.refresh();
},

View File

@@ -3,7 +3,7 @@ const _ = require('lodash');
const Marked = require('marked');
const MarkedExtendedTables = require('marked-extended-tables');
const { markedSmartypantsLite: MarkedSmartypantsLite } = require('marked-smartypants-lite');
const { gfmHeadingId: MarkedGFMHeadingId } = require('marked-gfm-heading-id');
const { gfmHeadingId: MarkedGFMHeadingId, resetHeadings: MarkedGFMResetHeadingIDs } = require('marked-gfm-heading-id');
const { markedEmoji: MarkedEmojis } = require('marked-emoji');
//Icon fonts included so they can appear in emoji autosuggest dropdown
@@ -86,7 +86,7 @@ renderer.link = function (href, title, text) {
if(href[0] == '#') {
self = true;
}
href = cleanUrl(this.options.sanitize, this.options.baseUrl, href);
href = cleanUrl(href);
if(href === null) {
return text;
@@ -102,6 +102,20 @@ renderer.link = function (href, title, text) {
return out;
};
// Expose `src` attribute as `--HB_src` to make the URL accessible via CSS
renderer.image = function (href, title, text) {
href = cleanUrl(href);
if(href === null)
return text;
let out = `<img src="${href}" alt="${text}" style="--HB_src:url(${href});"`;
if(title)
out += ` title="${title}"`;
out += '>';
return out;
};
// Disable default reflink behavior, as it steps on our variables extension
tokenizer.def = function () {
return undefined;
@@ -356,6 +370,27 @@ const superSubScripts = {
}
};
const forcedParagraphBreaks = {
name : 'hardBreaks',
level : 'block',
start(src) { return src.match(/\n:+$/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const regex = /^(:+)(?:\n|$)/ym;
const match = regex.exec(src);
if(match?.length) {
return {
type : 'hardBreaks', // Should match "name" above
raw : match[0], // Text to consume from the source
length : match[1].length,
text : ''
};
}
},
renderer(token) {
return `<div class='blank'></div>`.repeat(token.length).concat('\n');
}
};
const definitionListsSingleLine = {
name : 'definitionListsSingleLine',
level : 'block',
@@ -400,9 +435,9 @@ const definitionListsSingleLine = {
const definitionListsMultiLine = {
name : 'definitionListsMultiLine',
level : 'block',
start(src) { return src.match(/\n[^\n]*\n::/m)?.index; }, // Hint to Marked.js to stop and check for a match
start(src) { return src.match(/\n[^\n]*\n::[^:\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const regex = /(\n?\n?(?!::)[^\n]+?(?=\n::))|\n::(.(?:.|\n)*?(?=(?:\n::)|(?:\n\n)|$))/y;
const regex = /(\n?\n?(?!::)[^\n]+?(?=\n::[^:\n]))|\n::([^:\n](?:.|\n)*?(?=(?:\n::)|(?:\n\n)|$))/y;
let match;
let endIndex = 0;
const definitions = [];
@@ -706,34 +741,27 @@ const MarkedEmojiOptions = {
renderer : (token)=>`<i class="${token.emoji}"></i>`
};
const tableTerminators = [
`:+\\n`, // hardBreak
` *{[^\n]+}`, // blockInjector
` *{{[^{\n]*\n.*?\n}}` // mustacheDiv
];
Marked.use(MarkedVariables());
Marked.use({ extensions: [definitionListsMultiLine, definitionListsSingleLine, superSubScripts, mustacheSpans, mustacheDivs, mustacheInjectInline] });
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, superSubScripts,
mustacheSpans, mustacheDivs, mustacheInjectInline] });
Marked.use(mustacheInjectBlock);
Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
Marked.use(MarkedExtendedTables(), MarkedGFMHeadingId(), MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }), MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
const nonWordAndColonTest = /[^\w:]/g;
const cleanUrl = function (sanitize, base, href) {
if(sanitize) {
let prot;
try {
prot = decodeURIComponent(unescape(href))
.replace(nonWordAndColonTest, '')
.toLowerCase();
} catch (e) {
return null;
}
if(prot.indexOf('javascript:') === 0 || prot.indexOf('vbscript:') === 0 || prot.indexOf('data:') === 0) {
return null;
}
}
function cleanUrl(href) {
try {
href = encodeURI(href).replace(/%25/g, '%');
} catch (e) {
} catch {
return null;
}
return href;
};
}
const escapeTest = /[&<>"']/;
const escapeReplace = /[&<>"']/g;
@@ -828,13 +856,15 @@ let globalPageNumber = 0;
module.exports = {
marked : Marked,
render : (rawBrewText, pageNumber=1)=>{
globalVarsList[pageNumber] = {}; //Reset global links for current page, to ensure values are parsed in order
varsQueue = []; //Could move into MarkedVariables()
globalPageNumber = pageNumber;
render : (rawBrewText, pageNumber=0)=>{
globalVarsList[pageNumber] = {}; //Reset global links for current page, to ensure values are parsed in order
varsQueue = []; //Could move into MarkedVariables()
globalPageNumber = pageNumber;
if(pageNumber==0) {
MarkedGFMResetHeadingIDs();
}
rawBrewText = rawBrewText.replace(/^\\column$/gm, `\n<div class='columnSplit'></div>\n`)
.replace(/^(:+)$/gm, (match)=>`${`<div class='blank'></div>`.repeat(match.length)}\n`);
rawBrewText = rawBrewText.replace(/^\\column$/gm, `\n<div class='columnSplit'></div>\n`);
const opts = Marked.defaults;
rawBrewText = opts.hooks.preprocess(rawBrewText);

View File

@@ -7,8 +7,9 @@ const SplitPane = createClass({
displayName : 'SplitPane',
getDefaultProps : function() {
return {
storageKey : 'naturalcrit-pane-split',
onDragFinish : function(){} //fires when dragging
storageKey : 'naturalcrit-pane-split',
onDragFinish : function(){}, //fires when dragging
showDividerButtons : true
};
},
@@ -41,6 +42,10 @@ const SplitPane = createClass({
});
}
window.addEventListener('resize', this.handleWindowResize);
// This lives here instead of in the initial render because you cannot touch localStorage until the componant mounts.
const loadLiveScroll = window.localStorage.getItem('liveScroll') === 'true';
this.setState({ liveScroll: loadLiveScroll });
},
componentWillUnmount : function() {
@@ -88,6 +93,11 @@ const SplitPane = createClass({
userSetDividerPos : newSize
});
},
liveScrollToggle : function() {
window.localStorage.setItem('liveScroll', String(!this.state.liveScroll));
this.setState({ liveScroll: !this.state.liveScroll });
},
/*
unFocus : function() {
if(document.selection){
@@ -119,13 +129,18 @@ const SplitPane = createClass({
onClick={()=>this.setState({ moveBrew: !this.state.moveBrew })} >
<i className='fas fa-arrow-right' />
</div>
<div id='scrollToggleDiv' className={this.state.liveScroll ? 'arrow lock' : 'arrow unlock'}
style={{ left: this.state.currentDividerPos-4 }}
onClick={this.liveScrollToggle} >
<i id='scrollToggle' className={this.state.liveScroll ? 'fas fa-lock' : 'fas fa-unlock'} />
</div>
</>;
}
},
renderDivider : function(){
return <>
{this.renderMoveArrows()}
{this.props.showDividerButtons && this.renderMoveArrows()}
<div className='divider' onPointerDown={this.handleDown} >
<div className='dots'>
<i className='fas fa-circle' />
@@ -142,9 +157,12 @@ const SplitPane = createClass({
width={this.state.currentDividerPos}
>
{React.cloneElement(this.props.children[0], {
moveBrew : this.state.moveBrew,
moveSource : this.state.moveSource,
setMoveArrows : this.setMoveArrows
...(this.props.showDividerButtons && {
moveBrew : this.state.moveBrew,
moveSource : this.state.moveSource,
liveScroll : this.state.liveScroll,
setMoveArrows : this.setMoveArrows,
}),
})}
</Pane>
{this.renderDivider()}

View File

@@ -53,6 +53,15 @@
.tooltipRight('Jump to location in Preview');
top : 60px;
}
&.lock{
.tooltipRight('De-sync Editor and Preview locations.');
top : 90px;
background: #666;
}
&.unlock{
.tooltipRight('Sync Editor and Preview locations');
top : 90px;
}
&:hover{
background-color: #666;
}

View File

@@ -88,4 +88,16 @@ describe('Multiline Definition Lists', ()=>{
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt><dd>Inline definition 1</dd>\n<dt></dt><dd>Inline definition 2 (no DT)</dd>\n</dl>');
});
test('Multiline Definition Term must have at least one non-empty Definition', function() {
const source = 'Term 1\n::';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Term 1</p>\n<div class='blank'></div><div class='blank'></div>`);
});
test('Multiline Definition List must have at least one non-newline character after ::', function() {
const source = 'Term 1\n::\nDefinition 1\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Term 1</p>\n<div class='blank'></div><div class='blank'></div>\n<p>Definition 1</p>`);
});
});

View File

@@ -0,0 +1,47 @@
/* eslint-disable max-lines */
const Markdown = require('naturalcrit/markdown.js');
describe('Hard Breaks', ()=>{
test('Single Break', function() {
const source = ':\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div>`);
});
test('Double Break', function() {
const source = '::\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div><div class='blank'></div>`);
});
test('Triple Break', function() {
const source = ':::\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div><div class='blank'></div><div class='blank'></div>`);
});
test('Many Break', function() {
const source = '::::::::::\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div><div class='blank'></div>`);
});
test('Multiple sets of Breaks', function() {
const source = ':::\n:::\n:::';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div><div class='blank'></div><div class='blank'></div>\n<div class='blank'></div><div class='blank'></div><div class='blank'></div>\n<div class='blank'></div><div class='blank'></div><div class='blank'></div>`);
});
test('Break directly between two paragraphs', function() {
const source = 'Line 1\n::\nLine 2';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Line 1</p>\n<div class='blank'></div><div class='blank'></div>\n<p>Line 2</p>`);
});
test('Ignored inside a code block', function() {
const source = '```\n\n:\n\n```\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<pre><code>\n:\n</code></pre>`);
});
});

View File

@@ -322,9 +322,9 @@ describe('Injection: When an injection tag follows an element', ()=>{
});
it('Renders an image element with injected style', function() {
const source = '![alt text](http://i.imgur.com/hMna6G0.png){position:absolute}';
const source = '![alt text](https://i.imgur.com/hMna6G0.png){position:absolute}';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><img style="position:absolute;" src="http://i.imgur.com/hMna6G0.png" alt="alt text"></p>');
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute;" src="https://i.imgur.com/hMna6G0.png" alt="alt text"></p>');
});
it('Renders an element modified by only the first of two consecutive injections', function() {
@@ -343,19 +343,19 @@ describe('Injection: When an injection tag follows an element', ()=>{
it('Renders an image with added attributes', function() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e"></p>`);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e"></p>`);
});
it('Renders an image with "=" in the url, and added attributes', function() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png?auth=12345&height=1024) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png?auth=12345&height=1024" alt="homebrew mug" a="b and c" d="e"></p>`);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png?auth=12345&height=1024); position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png?auth=12345&height=1024" alt="homebrew mug" a="b and c" d="e"></p>`);
});
it('Renders an image and added attributes with "=" in the value, ', function() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e,otherUrl="url?auth=12345"}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e" otherUrl="url?auth=12345"></p>`);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e" otherUrl="url?auth=12345"></p>`);
});
});

View File

@@ -315,21 +315,21 @@ describe('Normal Links and Images', ()=>{
const source = `![alt text](url)`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p><img src="url" alt="alt text"></p>`.trimReturns());
<p><img src="url" alt="alt text" style="--HB_src:url(url);"></p>`.trimReturns());
});
it('Renders normal images with a title', function() {
const source = 'An image ![alt text](url "and title")!';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p>An image <img src="url" alt="alt text" title="and title">!</p>`.trimReturns());
<p>An image <img src="url" alt="alt text" style="--HB_src:url(url);" title="and title">!</p>`.trimReturns());
});
it('Applies curly injectors to images', function() {
const source = `![alt text](url){width:100px}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p><img style="width:100px;" src="url" alt="alt text"></p>`.trimReturns());
<p><img style="--HB_src:url(url); width:100px;" src="url" alt="alt text"></p>`.trimReturns());
});
it('Renders normal links', function() {

View File

@@ -27,35 +27,154 @@ module.exports = [
experimental : true,
subsnippets : [
{
name : 'Table of Contents',
name : 'Generate Table of Contents',
icon : 'fas fa-book',
gen : TableOfContentsGen,
experimental : true
},
{
name : 'Include in ToC up to H3',
icon : 'fas fa-dice-three',
name : 'Table of Contents Individual Inclusion',
icon : 'fas fa-book',
gen : dedent `\n{{tocInclude# CHANGE # to your header level
}}\n`,
subsnippets : [
{
name : 'Individual Inclusion H1',
icon : 'fas fa-book',
gen : dedent `\n{{tocIncludeH1 \n
}}\n`,
},
{
name : 'Individual Inclusion H2',
icon : 'fas fa-book',
gen : dedent `\n{{tocIncludeH2 \n
}}\n`,
},
{
name : 'Individual Inclusion H3',
icon : 'fas fa-book',
gen : dedent `\n{{tocIncludeH3 \n
}}\n`,
},
{
name : 'Individual Inclusion H4',
icon : 'fas fa-book',
gen : dedent `\n{{tocIncludeH4 \n
}}\n`,
},
{
name : 'Individual Inclusion H5',
icon : 'fas fa-book',
gen : dedent `\n{{tocIncludeH5 \n
}}\n`,
},
{
name : 'Individual Inclusion H6',
icon : 'fas fa-book',
gen : dedent `\n{{tocIncludeH6 \n
}}\n`,
}
]
},
{
name : 'Table of Contents Range Inclusion',
icon : 'fas fa-book',
gen : dedent `\n{{tocDepthH3
}}\n`,
subsnippets : [
{
name : 'Include in ToC up to H3',
icon : 'fas fa-dice-three',
gen : dedent `\n{{tocDepthH3
}}\n`,
},
{
name : 'Include in ToC up to H4',
icon : 'fas fa-dice-four',
gen : dedent `\n{{tocDepthH4
}}\n`,
},
{
name : 'Include in ToC up to H5',
icon : 'fas fa-dice-five',
gen : dedent `\n{{tocDepthH5
}}\n`,
},
{
name : 'Include in ToC up to H6',
icon : 'fas fa-dice-six',
gen : dedent `\n{{tocDepthH6
}}\n`,
},
]
},
{
name : 'Include in ToC up to H4',
icon : 'fas fa-dice-four',
gen : dedent `\n{{tocDepthH4
name : 'Table of Contents Individual Exclusion',
icon : 'fas fa-book',
gen : dedent `\n{{tocExcludeH1 \n
}}\n`,
subsnippets : [
{
name : 'Individual Exclusion H1',
icon : 'fas fa-book',
gen : dedent `\n{{tocExcludeH1 \n
}}\n`,
},
{
name : 'Individual Exclusion H2',
icon : 'fas fa-book',
gen : dedent `\n{{tocExcludeH2 \n
}}\n`,
},
{
name : 'Individual Exclusion H3',
icon : 'fas fa-book',
gen : dedent `\n{{tocExcludeH3 \n
}}\n`,
},
{
name : 'Individual Exclusion H4',
icon : 'fas fa-book',
gen : dedent `\n{{tocExcludeH4 \n
}}\n`,
},
{
name : 'Individual Exclusion H5',
icon : 'fas fa-book',
gen : dedent `\n{{tocExcludeH5 \n
}}\n`,
},
{
name : 'Individual Exclusion H6',
icon : 'fas fa-book',
gen : dedent `\n{{tocExcludeH6 \n
}}\n`,
},
]
},
{
name : 'Include in ToC up to H5',
icon : 'fas fa-dice-five',
gen : dedent `\n{{tocDepthH5
}}\n`,
},
{
name : 'Include in ToC up to H6',
icon : 'fas fa-dice-six',
gen : dedent `\n{{tocDepthH6
}}\n`,
name : 'Table of Contents Toggles',
icon : 'fas fa-book',
gen : `{{tocGlobalH4}}\n\n`,
subsnippets : [
{
name : 'Enable H1-H4 all pages',
icon : 'fas fa-dice-four',
gen : `{{tocGlobalH4}}\n\n`,
},
{
name : 'Enable H1-H5 all pages',
icon : 'fas fa-dice-five',
gen : `{{tocGlobalH5}}\n\n`,
},
{
name : 'Enable H1-H6 all pages',
icon : 'fas fa-dice-six',
gen : `{{tocGlobalH6}}\n\n`,
},
]
}
]
},
@@ -94,7 +213,7 @@ module.exports = [
background-image: linear-gradient(-45deg, #322814, #998250, #322814);
line-height: 1em;
}\n\n`
}
},
]
},

View File

@@ -1,77 +1,78 @@
const _ = require('lodash');
const dedent = require('dedent-tabs').default;
const getTOC = (pages)=>{
// Map each actual page to its footer label, accounting for skips or numbering resets
const mapPages = (pages)=>{
let actualPage = 0;
let mappedPage = 0; // Number displayed in footer
const pageMap = [];
const recursiveAdd = (title, page, targetDepth, child, curDepth=0)=>{
if(curDepth > 5) return; // Something went wrong.
if(curDepth == targetDepth) {
child.push({
title : title,
page : page,
children : []
});
} else {
if(child.length == 0) {
child.push({
title : null,
page : page,
children : []
});
pages.forEach((page)=>{
actualPage++;
const doSkip = page.querySelector('.skipCounting');
const doReset = page.querySelector('.resetCounting');
if(doReset)
mappedPage = 1;
if(!doSkip && !doReset)
mappedPage++;
pageMap[actualPage] = {
mappedPage : mappedPage,
showPage : !doSkip
};
});
return pageMap;
};
const getMarkdown = (headings, pageMap)=>{
const levelPad = ['- ###', ' - ####', ' -', ' -', ' -', ' -'];
const allMarkdown = [];
const depthChain = [0];
headings.forEach((heading)=>{
const page = parseInt(heading.closest('.page').id?.replace(/^p/, ''));
const mappedPage = pageMap[page].mappedPage;
const showPage = pageMap[page].showPage;
const title = heading.textContent.trim();
const ToCExclude = getComputedStyle(heading).getPropertyValue('--TOC');
const depth = parseInt(heading.tagName.substring(1));
if(!title || !showPage || ToCExclude == 'exclude')
return;
//If different header depth than last, remove indents until nearest higher-level header, then indent once
if(depth !== depthChain[depthChain.length -1]) {
while (depth <= depthChain[depthChain.length - 1]) {
depthChain.pop();
}
recursiveAdd(title, page, targetDepth, _.last(child).children, curDepth+1,);
depthChain.push(depth);
}
};
const res = [];
const markdown = `${levelPad[depthChain.length - 2]} [{{ ${title}}}{{ ${mappedPage}}}](#p${page})`;
allMarkdown.push(markdown);
});
return allMarkdown.join('\n');
};
const getTOC = ()=>{
const iframe = document.getElementById('BrewRenderer');
const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
const headings = iframeDocument.querySelectorAll('h1, h2, h3, h4, h5, h6');
const headerDepth = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
const pages = iframeDocument.querySelectorAll('.page');
_.each(headings, (heading)=>{
const onPage = parseInt(heading.closest('.page').id?.replace(/^p/, ''));
const ToCExclude = getComputedStyle(heading).getPropertyValue('--TOC');
if(ToCExclude != 'exclude') {
recursiveAdd(heading.textContent.trim(), onPage, headerDepth.indexOf(heading.tagName), res);
}
});
return res;
};
const ToCIterate = (entries, curDepth=0)=>{
const levelPad = ['- ###', ' - ####', ' - ', ' - ', ' - ', ' - '];
const toc = [];
if(entries.title !== null){
toc.push(`${levelPad[curDepth]} [{{ ${entries.title}}}{{ ${entries.page}}}](#p${entries.page})`);
}
if(entries.children.length) {
_.each(entries.children, (entry, idx)=>{
const children = ToCIterate(entry, entry.title == null ? curDepth : curDepth+1);
if(children.length) {
toc.push(...children);
}
});
}
return toc;
const pageMap = mapPages(pages);
return getMarkdown(headings, pageMap);
};
module.exports = function(props){
const pages = props.brew.text.split('\\page');
const TOC = getTOC(pages);
const markdown = _.reduce(TOC, (r, g1, idx1)=>{
r.push(ToCIterate(g1).join('\n'));
return r;
}, []).join('\n');
const TOC = getTOC();
return dedent`
{{toc,wide
# Contents
${markdown}
${TOC}
}}
\n`;
};
};

View File

@@ -11,6 +11,7 @@
--HB_Color_CaptionText : #766649; // Brown
--HB_Color_WatercolorStain : #BBAD82; // Light brown
--HB_Color_Footnotes : #C9AD6A; // Gold
--TOC : 'include';
}
.useSansSerif() {
@@ -797,7 +798,7 @@
// *****************************/
// Default Exclusions
// Anything not exlcuded is included, default Headers are H1, H2, and H3.
// Anything not excluded is included, default Headers are H1, H2, and H3.
h4,
h5,
h6,
@@ -808,12 +809,23 @@ h6,
.noToC,
.toc { --TOC: exclude; }
.tocDepthH2 :is(h1, h2) {--TOC: include; }
.tocDepthH3 :is(h1, h2, h3) {--TOC: include; }
.tocDepthH4 :is(h1, h2, h3, h4) {--TOC: include; }
.tocDepthH5 :is(h1, h2, h3, h4, h5) {--TOC: include; }
.tocDepthH6 :is(h1, h2, h3, h4, h5, h6) {--TOC: include; }
// Brew level default inclusion changes.
// These add Headers 'back' to inclusion.
.pages:has(.tocGlobalH4) {
h4 {--TOC: include; }
}
.pages:has(.tocGlobalH5) {
h4, h5 {--TOC: include; }
}
.pages:has(.tocGlobalH6) {
h4, h5, h6 {--TOC: include; }
}
// Block level inclusion changes
// These include either a single (include) or a range (depth)
.tocIncludeH1 h1 {--TOC: include; }
.tocIncludeH2 h2 {--TOC: include; }
.tocIncludeH3 h3 {--TOC: include; }
@@ -821,6 +833,21 @@ h6,
.tocIncludeH5 h5 {--TOC: include; }
.tocIncludeH6 h6 {--TOC: include; }
.tocDepthH2 :is(h1, h2) {--TOC: include; }
.tocDepthH3 :is(h1, h2, h3) {--TOC: include; }
.tocDepthH4 :is(h1, h2, h3, h4) {--TOC: include; }
.tocDepthH5 :is(h1, h2, h3, h4, h5) {--TOC: include; }
.tocDepthH6 :is(h1, h2, h3, h4, h5, h6) {--TOC: include; }
// Block level exclusion changes
// These exclude a single block level
.tocExcludeH1 h1 {--TOC: exclude; }
.tocExcludeH2 h2 {--TOC: exclude; }
.tocExcludeH3 h3 {--TOC: exclude; }
.tocExcludeH4 h4 {--TOC: exclude; }
.tocExcludeH5 h5 {--TOC: exclude; }
.tocExcludeH6 h6 {--TOC: exclude; }
.page:has(.partCover) {
--TOC: exclude;
& h1 {

View File

@@ -23,14 +23,30 @@ module.exports = [
gen : '\n\\page\n'
},
{
name : 'Page Number',
icon : 'fas fa-bookmark',
gen : '{{pageNumber 1}}\n'
},
{
name : 'Auto-incrementing Page Number',
icon : 'fas fa-sort-numeric-down',
gen : '{{pageNumber,auto}}\n'
name : 'Page Numbering',
icon : 'fas fa-bookmark',
subsnippets : [
{
name : 'Page Number',
icon : 'fas fa-bookmark',
gen : '{{pageNumber 1}}\n'
},
{
name : 'Auto-incrementing Page Number',
icon : 'fas fa-sort-numeric-down',
gen : '{{pageNumber,auto}}\n'
},
{
name : 'Skip Page Number Increment this Page',
icon : 'fas fa-xmark',
gen : '{{skipCounting}}\n'
},
{
name : 'Restart Numbering',
icon : 'fas fa-arrow-rotate-left',
gen : '{{resetCounting}}\n'
},
]
},
{
name : 'Footer',
@@ -153,6 +169,18 @@ module.exports = [
gen : dedent`
![cat warrior](https://s-media-cache-ak0.pinimg.com/736x/4a/81/79/4a8179462cfdf39054a418efd4cb743e.jpg) {width:325px,mix-blend-mode:multiply}`
},
{
name : 'Image Wrap Left',
icon : 'fac image-wrap-left',
gen : dedent`
![homebrewery_mug](http://i.imgur.com/hMna6G0.png) {width:280px,margin-right:-3cm,wrapLeft}`
},
{
name : 'Image Wrap Right',
icon : 'fac image-wrap-right',
gen : dedent`
![homebrewery_mug](http://i.imgur.com/hMna6G0.png) {width:280px,margin-left:-3cm,wrapRight}`
},
{
name : 'Background Image',
icon : 'fas fa-tree',
@@ -398,22 +426,40 @@ module.exports = [
]
},
/**************** PAGE *************/
/**************** LAYOUT *************/
{
groupName : 'Print',
icon : 'fas fa-print',
view : 'style',
snippets : [
{
name : 'A3 Page Size',
icon : 'far fa-file',
gen : dedent`/* A3 Page Size */
.page {
width : 297mm;
height : 420mm;
}\n\n`,
},
{
name : 'A4 Page Size',
icon : 'far fa-file',
gen : dedent`/* A4 Page Size */
.page{
.page {
width : 210mm;
height : 296.8mm;
}\n\n`
},
{
name : 'A5 Page Size',
icon : 'far fa-file',
gen : dedent`/* A5 Page Size */
.page {
width : 148mm;
height : 210mm;
}\n\n`,
},
{
name : 'Square Page Size',
icon : 'far fa-file',
@@ -425,6 +471,17 @@ module.exports = [
columns : unset;
}\n\n`
},
{
name : 'Card Page Size',
icon : 'far fa-file',
gen : dedent`/* Card Size */
.page {
width : 63.5mm;
height : 88.9mm;
padding : 5mm;
columns : unset;
}\n\n`
},
{
name : 'Ink Friendly',
icon : 'fas fa-tint',
@@ -440,5 +497,5 @@ module.exports = [
}\n\n`
},
]
}
},
];

View File

@@ -12,7 +12,7 @@
}
@page { margin : 0; }
body { counter-reset : page-numbers; }
body { counter-reset : page-numbers 0; }
* { -webkit-print-color-adjust : exact; }
//*****************************
@@ -51,7 +51,6 @@ body { counter-reset : page-numbers; }
height : 279.4mm;
padding : 1.4cm 1.9cm 1.7cm;
overflow : hidden;
counter-increment : page-numbers;
background-color : var(--HB_Color_Background);
text-rendering : optimizeLegibility;
contain : size;
@@ -156,6 +155,19 @@ body { counter-reset : page-numbers; }
break-inside : avoid;
}
/* Wrap Text */
.wrapLeft {
shape-outside : var(--HB_src);
float : right;
shape-margin : 0.2cm;
}
.wrapRight {
shape-outside : var(--HB_src);
float : left;
shape-margin : 0.2cm;
}
/* Watermark */
.watermark {
position : absolute;
@@ -236,7 +248,7 @@ body { counter-reset : page-numbers; }
left : 50%;
width : 50%;
height : 50%;
transform : translateX(-50%) translateY(50%) rotate(calc(-1deg * var(--rotation))) scaleX(calc(1 / var(--scaleX))) scaleY(calc(1 / var(--scaleY)));
transform : translateX(-50%) translateY(50%) scaleX(calc(1 / var(--scaleX))) scaleY(calc(1 / var(--scaleY))) rotate(calc(-1deg * var(--rotation)));
}
& img {
position : absolute;
@@ -481,4 +493,13 @@ body { counter-reset : page-numbers; }
&:nth-child(even) {
.pageNumber { left : 30px; }
}
}
.resetCounting {
counter-set : page-numbers 1;
}
&:not(:has(.skipCounting)) {
counter-increment : page-numbers;
}
}