0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-03 08:22:42 +00:00

Merge branch 'master' of https://github.com/naturalcrit/homebrewery into fix-vulnerability-admin-pages

This commit is contained in:
Víctor Losada Hernández
2024-09-16 22:29:16 +02:00
87 changed files with 19679 additions and 16892 deletions

View File

@@ -67,6 +67,9 @@ jobs:
- run: - run:
name: Test - Definition Lists name: Test - Definition Lists
command: npm run test:definition-lists command: npm run test:definition-lists
- run:
name: Test - Hard Breaks
command: npm run test:hard-breaks
- run: - run:
name: Test - Variables name: Test - Variables
command: npm run 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

@@ -3,7 +3,7 @@
"stylelint-config-recess-order", "stylelint-config-recess-order",
"stylelint-config-recommended"], "stylelint-config-recommended"],
"plugins": [ "plugins": [
"stylelint-stylistic", "@stylistic/stylelint-plugin",
"./stylelint_plugins/declaration-colon-align.js", "./stylelint_plugins/declaration-colon-align.js",
"./stylelint_plugins/declaration-colon-min-space-before", "./stylelint_plugins/declaration-colon-min-space-before",
"./stylelint_plugins/declaration-block-multi-line-min-declarations" "./stylelint_plugins/declaration-block-multi-line-min-declarations"
@@ -16,32 +16,32 @@
"font-family-no-missing-generic-family-keyword" : null, "font-family-no-missing-generic-family-keyword" : null,
"font-weight-notation" : "named-where-possible", "font-weight-notation" : "named-where-possible",
"font-family-name-quotes" : "always-unless-keyword", "font-family-name-quotes" : "always-unless-keyword",
"stylistic/indentation" : "tab", "@stylistic/indentation" : "tab",
"no-duplicate-selectors" : true, "no-duplicate-selectors" : true,
"stylistic/color-hex-case" : "upper", "@stylistic/color-hex-case" : "upper",
"color-hex-length" : "long", "color-hex-length" : "long",
"stylistic/selector-combinator-space-after" : "always", "@stylistic/selector-combinator-space-after" : "always",
"stylistic/selector-combinator-space-before" : "always", "@stylistic/selector-combinator-space-before" : "always",
"stylistic/selector-attribute-operator-space-before" : "never", "@stylistic/selector-attribute-operator-space-before" : "never",
"stylistic/selector-attribute-operator-space-after" : "never", "@stylistic/selector-attribute-operator-space-after" : "never",
"stylistic/selector-attribute-brackets-space-inside" : "never", "@stylistic/selector-attribute-brackets-space-inside" : "never",
"selector-attribute-quotes" : "always", "selector-attribute-quotes" : "always",
"selector-pseudo-element-colon-notation" : "double", "selector-pseudo-element-colon-notation" : "double",
"stylistic/selector-pseudo-class-parentheses-space-inside" : "never", "@stylistic/selector-pseudo-class-parentheses-space-inside" : "never",
"stylistic/block-opening-brace-space-before" : "always", "@stylistic/block-opening-brace-space-before" : "always",
"naturalcrit/declaration-colon-min-space-before" : 1, "naturalcrit/declaration-colon-min-space-before" : 1,
"stylistic/declaration-block-trailing-semicolon" : "always", "@stylistic/declaration-block-trailing-semicolon" : "always",
"stylistic/declaration-colon-space-after" : "always", "@stylistic/declaration-colon-space-after" : "always",
"stylistic/number-leading-zero" : "always", "@stylistic/number-leading-zero" : "always",
"function-url-quotes" : ["always", { "except": ["empty"] }], "function-url-quotes" : ["always", { "except": ["empty"] }],
"function-url-scheme-disallowed-list" : ["data","http"], "function-url-scheme-disallowed-list" : ["data","http"],
"comment-whitespace-inside" : "always", "comment-whitespace-inside" : "always",
"stylistic/string-quotes" : "single", "@stylistic/string-quotes" : "single",
"stylistic/media-feature-range-operator-space-before" : "always", "@stylistic/media-feature-range-operator-space-before" : "always",
"stylistic/media-feature-range-operator-space-after" : "always", "@stylistic/media-feature-range-operator-space-after" : "always",
"stylistic/media-feature-parentheses-space-inside" : "never", "@stylistic/media-feature-parentheses-space-inside" : "never",
"stylistic/media-feature-colon-space-before" : "always", "@stylistic/media-feature-colon-space-before" : "always",
"stylistic/media-feature-colon-space-after" : "always", "@stylistic/media-feature-colon-space-after" : "always",
"naturalcrit/declaration-colon-align" : true, "naturalcrit/declaration-colon-align" : true,
"naturalcrit/declaration-block-multi-line-min-declarations": 1 "naturalcrit/declaration-block-multi-line-min-declarations": 1
} }

View File

@@ -1,4 +1,4 @@
FROM node:18-alpine FROM node:20-alpine
RUN apk --no-cache add git RUN apk --no-cache add git
ENV NODE_ENV=docker ENV NODE_ENV=docker

View File

@@ -84,6 +84,233 @@ pre {
## changelog ## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### 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
Fixes issues [#2563](https://github.com/naturalcrit/homebrewery/issues/2563)
* [x] Fix unusual margin spacing for adjacent `.descriptive` and `.wide` blocks
Fixes issues [#2688](https://github.com/naturalcrit/homebrewery/issues/2688)
* [x] Add code folding to :fas_paintbrush: {{openSans **STYLE**}} tab
##### G-Ambatte
* [x] Fix edge case where Table of Contents generator changed capitalization of headings
Fixes issues [#3572](https://github.com/naturalcrit/homebrewery/issues/3572)
* [x] Fix **Ink Friendly** snippet causing unselectable PDF text
Fixes issues [#3563](https://github.com/naturalcrit/homebrewery/issues/3563)
* [x] Prevent brews selecting themselves as a theme
Fixes issues [#3614](https://github.com/naturalcrit/homebrewery/issues/3614)
* [x] Fix info pages (`/faq`, `/migrate`, etc.) showing blank authorship info
Fixes issues [#3568](https://github.com/naturalcrit/homebrewery/issues/3568)
* [x] Add `abs()`, `sign()` and `signed()` functions to variable syntax math handler
Fixes issues [#3537](https://github.com/naturalcrit/homebrewery/issues/3537)
* [x] Fix variable math handler not processing commas (i.e., in `$[max(varA,varB)]`
Fixes issues [#3613](https://github.com/naturalcrit/homebrewery/issues/3613)
* [x] Fix variable math handler scrambling variables with names that are subsets of other variables
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
### Monday 7/29/2024 - v3.14.0
{{taskList
##### abquintic, calculuschild
* [x] Alternative Brew Themes, including importing other brews as a base theme.
- In the :fas_circle_info: **Properties** menu, find the new {{openSans **THEME**}} dropdown. It lists Brew Themes, including a new **Blank** theme as a simpler basis for custom styling.
- Brews tagged with `meta:theme` will appear in the Brew Themes list. Selecting one loads its :fas_paintbrush: **Style** tab contents as the CSS basis for the current brew, allowing one brew to style multiple documents.
- Brews with `meta:theme` can also select their own Theme, i.e. layering Themes on top of each other.
- The next goal is to make **Published** Themes shareable between users.
Fixes issues [#1899](https://github.com/naturalcrit/homebrewery/issues/1899), [#3085](https://github.com/naturalcrit/homebrewery/issues/3085)
##### G-Ambatte
* [x] Fix Drop-cap font becoming corrupted when Bold
Fixes issues [#3551](https://github.com/naturalcrit/homebrewery/issues/3551)
* [x] Fixes to UI styling
Fixes issues [#3568](https://github.com/naturalcrit/homebrewery/issues/3568)
}}
### Saturday 6/7/2024 - v3.13.1
{{taskList
##### calculuschild, G-Ambatte
* [x] Hotfixes for issues with v3.13.0
Fixes issues [#3559](https://github.com/naturalcrit/homebrewery/issues/3559), [#3552](https://github.com/naturalcrit/homebrewery/issues/3552), [#3554](https://github.com/naturalcrit/homebrewery/issues/3554)
}}
### Friday 28/6/2024 - v3.13.0
{{taskList
##### calculuschild
* [x] Add `:emoji:` Markdown syntax, with autosuggest; start typing after the first `:` for matching emojis from
:fab_font_awesome: FontAwesome, :df_d20: DiceFont, :ei_action: ElderberryInn, and a subset of :gi_broadsword: GameIcons
* [x] Fix `{curly injection}` to append to, rather than erase and replace target CSS
* [x] {{openSans **GET PDF**}} {{fa,fa-file-pdf}} now opens the print dialog directly, rather than redirecting to a separate page
##### Gazook
* [x] Several small style tweaks to the UI
* [x] Cleaning and refactoring several large pieces of code
##### 5e-Cleric
* [x] For error pages, add links to user account and `/share` page if available
Fixes issue [#3298](https://github.com/naturalcrit/homebrewery/issues/3298)
* [x] Change FrontCover title to use stroke outline instead of faking it with dozens of shadows
* [x] Cleaning and refactoring several large pieces of CSS
##### abquintic
* [x] Added additional {{openSans **TABLE OF CONTENTS**}} snippet options. Explicitly include or exclude items from the ToC generation via CSS properties
`--TOC:exclude` or `--TOC:include`, or change the included header depth from 3 to 6 (default 3) with `tocDepthH6`
##### MurdoMaclachlan *(new contributor!)*
* [x] Added "proficiency bonus" to Monster Stat Block snippet.
Fixes issue [#3397](https://github.com/naturalcrit/homebrewery/issues/3397)
}}
### Monday 18/3/2024 - v3.12.0 ### Monday 18/3/2024 - v3.12.0
{{taskList {{taskList
@@ -375,7 +602,7 @@ Fixes issue [#2729](https://github.com/naturalcrit/homebrewery/issues/2729),
### Thursday 17/08/2023 - v3.9.2 ### Thursday 17/08/2023 - v3.9.2
{{taskList {{taskList
##### Calculuschild ##### calculuschild
* [x] Fix links to certain old Google Drive files * [x] Fix links to certain old Google Drive files
@@ -433,7 +660,7 @@ Fixes issue [#1924](https://github.com/naturalcrit/homebrewery/issues/1924)
### Friday 02/06/2023 - v3.9.0 ### Friday 02/06/2023 - v3.9.0
{{taskList {{taskList
##### Calculuschild ##### calculuschild
* [x] Fix some files not showing up on userpage when user has a large number of brews in Google Drive * [x] Fix some files not showing up on userpage when user has a large number of brews in Google Drive
@@ -530,7 +757,7 @@ Fixes issues [#2731](https://github.com/naturalcrit/homebrewery/issues/2731)
### Monday 13/03/2023 - v3.7.2 ### Monday 13/03/2023 - v3.7.2
{{taskList {{taskList
##### Calculuschild ##### calculuschild
* [x] Fix wide Monster Stat Blocks not spanning columns on Legacy * [x] Fix wide Monster Stat Blocks not spanning columns on Legacy
}} }}
@@ -553,7 +780,7 @@ Fixes issues [#1569](https://github.com/naturalcrit/homebrewery/issues/1569)
* [x] Updated the Google Drive icon * [x] Updated the Google Drive icon
* [x] Backend fix to unit tests failing intermittently * [x] Backend fix to unit tests failing intermittently
##### Calculuschild ##### calculuschild
* [x] Fix PDF pixelation on CoverPage text outlines * [x] Fix PDF pixelation on CoverPage text outlines
}} }}
@@ -565,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. **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!) * [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!)
@@ -709,7 +936,7 @@ Fixes issues [#1670](https://github.com/naturalcrit/homebrewery/issues/1670)
### Thursday 28/10/2022 - v3.3.1 ### Thursday 28/10/2022 - v3.3.1
{{taskList {{taskList
##### Calculuschild ##### calculuschild
* [x] Fixes to several broken CSS styles from v3.3.0 * [x] Fixes to several broken CSS styles from v3.3.0
@@ -724,7 +951,7 @@ Fixes issues [#2468](https://github.com/naturalcrit/homebrewery/issues/2468)
### Friday 19/10/2022 - v3.3.0 ### Friday 19/10/2022 - v3.3.0
{{taskList {{taskList
##### Calculuschild ##### calculuschild
* [x] Fix for tables broken by Chrome v106 * [x] Fix for tables broken by Chrome v106
@@ -807,7 +1034,7 @@ Fixes issues [#2317](https://github.com/naturalcrit/homebrewery/issues/2317), [
### Wednesday 31/08/2022 - v3.2.1 ### Wednesday 31/08/2022 - v3.2.1
{{taskList {{taskList
##### Calculuschild ##### calculuschild
* [x] Reference Links should now work inside tables * [x] Reference Links should now work inside tables
@@ -833,7 +1060,7 @@ Fixes issues [#2317](https://github.com/naturalcrit/homebrewery/issues/2317), [
### Saturday 27/08/2022 - v3.2.0 ### Saturday 27/08/2022 - v3.2.0
{{taskList {{taskList
##### Calculuschild ##### calculuschild
* [x] The V3 renderer is now the default for new brews. * [x] The V3 renderer is now the default for new brews.
@@ -860,7 +1087,7 @@ Fixes issues [#2317](https://github.com/naturalcrit/homebrewery/issues/2317), [
### Thursday 09/06/2022 - v3.1.1 ### Thursday 09/06/2022 - v3.1.1
{{taskList {{taskList
##### Calculuschild: ##### calculuschild:
* [x] Fixed class table decorations appearing on top of the table in PDF output. * [x] Fixed class table decorations appearing on top of the table in PDF output.
@@ -1704,4 +1931,4 @@ Massive changelog incoming:
* Added `phb.standalone.css` plus a build system for creating it * Added `phb.standalone.css` plus a build system for creating it
* Added page numbers and footer text * Added page numbers and footer text
* Page accent now flips each page * Page accent now flips each page

View File

@@ -0,0 +1,29 @@
// Dialog box, for popups and modal blocking messages
const React = require('react');
const { useRef, useEffect } = React;
function Dialog({ dismissKey, closeText = 'Close', blocking = false, ...rest }) {
const dialogRef = useRef(null);
useEffect(()=>{
if(!dismissKey || !localStorage.getItem(dismissKey)) {
blocking ? dialogRef.current?.showModal() : dialogRef.current?.show();
}
}, []);
const dismiss = ()=>{
dismissKey && localStorage.setItem(dismissKey, true);
dialogRef.current?.close();
};
return (
<dialog ref={dialogRef} onCancel={dismiss} {...rest}>
{rest.children}
<button className='dismiss' onClick={dismiss}>
{closeText}
</button>
</dialog>
);
};
export default Dialog;

View File

@@ -1,24 +1,24 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/ /*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./brewRenderer.less'); require('./brewRenderer.less');
const React = require('react'); const React = require('react');
const { useState, useRef, useEffect } = React; const { useState, useRef, useEffect, useCallback } = React;
const _ = require('lodash'); const _ = require('lodash');
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js'); const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
const Markdown = require('naturalcrit/markdown.js'); const Markdown = require('naturalcrit/markdown.js');
const ErrorBar = require('./errorBar/errorBar.jsx'); const ErrorBar = require('./errorBar/errorBar.jsx');
const ToolBar = require('./toolBar/toolBar.jsx');
//TODO: move to the brew renderer //TODO: move to the brew renderer
const RenderWarnings = require('homebrewery/renderWarnings/renderWarnings.jsx'); const RenderWarnings = require('homebrewery/renderWarnings/renderWarnings.jsx');
const NotificationPopup = require('./notificationPopup/notificationPopup.jsx'); const NotificationPopup = require('./notificationPopup/notificationPopup.jsx');
const Frame = require('react-frame-component').default; const Frame = require('react-frame-component').default;
const dedent = require('dedent-tabs').default; const dedent = require('dedent-tabs').default;
const { printCurrentBrew } = require('../../../shared/helpers.js');
const DOMPurify = require('dompurify'); const DOMPurify = require('dompurify');
const purifyConfig = { FORCE_BODY: true, SANITIZE_DOM: false }; const purifyConfig = { FORCE_BODY: true, SANITIZE_DOM: false };
const Themes = require('themes/themes.json');
const PAGE_HEIGHT = 1056; const PAGE_HEIGHT = 1056;
const INITIAL_CONTENT = dedent` const INITIAL_CONTENT = dedent`
@@ -36,7 +36,7 @@ const BrewPage = (props)=>{
index : 0, index : 0,
...props ...props
}; };
const cleanText = DOMPurify.sanitize(props.contents, purifyConfig); const cleanText = props.contents; //DOMPurify.sanitize(props.contents, purifyConfig);
return <div className={props.className} id={`p${props.index + 1}`} > return <div className={props.className} id={`p${props.index + 1}`} >
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} /> <div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
</div>; </div>;
@@ -49,21 +49,25 @@ let rawPages = [];
const BrewRenderer = (props)=>{ const BrewRenderer = (props)=>{
props = { props = {
text : '', text : '',
style : '', style : '',
renderer : 'legacy', renderer : 'legacy',
theme : '5ePHB', theme : '5ePHB',
lang : '', lang : '',
errors : [], errors : [],
currentEditorPage : 0, currentEditorCursorPageNum : 0,
currentEditorViewPageNum : 0,
currentBrewRendererPageNum : 0,
themeBundle : {},
onPageChange : ()=>{},
...props ...props
}; };
const [state, setState] = useState({ const [state, setState] = useState({
viewablePageNumber : 0, height : PAGE_HEIGHT,
height : PAGE_HEIGHT, isMounted : false,
isMounted : false, visibility : 'hidden',
visibility : 'hidden', zoom : 100
}); });
const mainRef = useRef(null); const mainRef = useRef(null);
@@ -85,38 +89,27 @@ const BrewRenderer = (props)=>{
})); }));
}; };
const handleScroll = (e)=>{ const updateCurrentPage = useCallback(_.throttle((e)=>{
const target = e.target; const { scrollTop, clientHeight, scrollHeight } = e.target;
setState((prevState)=>({ const totalScrollableHeight = scrollHeight - clientHeight;
...prevState, const currentPageNumber = Math.max(Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length), 1);
viewablePageNumber : Math.floor(target.scrollTop / target.scrollHeight * rawPages.length)
})); props.onPageChange(currentPageNumber);
}; }, 200), []);
const isInView = (index)=>{ const isInView = (index)=>{
if(!state.isMounted) if(!state.isMounted)
return false; return false;
if(index == props.currentEditorPage) //Already rendered before this step if(index == props.currentEditorCursorPageNum - 1) //Already rendered before this step
return false; return false;
if(Math.abs(index - state.viewablePageNumber) <= 3) if(Math.abs(index - props.currentBrewRendererPageNum - 1) <= 3)
return true; return true;
return false; 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)=>{ const renderDummyPage = (index)=>{
return <div className='phb page' id={`p${index + 1}`} key={index}> return <div className='phb page' id={`p${index + 1}`} key={index}>
<i className='fas fa-spinner fa-spin' /> <i className='fas fa-spinner fa-spin' />
@@ -124,10 +117,9 @@ const BrewRenderer = (props)=>{
}; };
const renderStyle = ()=>{ const renderStyle = ()=>{
if(!props.style) return; const cleanStyle = props.style; //DOMPurify.sanitize(props.style, purifyConfig);
const cleanStyle = DOMPurify.sanitize(props.style, purifyConfig); const themeStyles = props.themeBundle?.joinedStyles ?? '<style>@import url("/themes/V3/Blank/style.css");</style>';
//return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `<style>@layer styleTab {\n${sanitizeScriptTags(props.style)}\n} </style>` }} />; return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `${themeStyles} \n\n <style> ${cleanStyle} </style>` }} />;
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `<style> ${cleanStyle} </style>` }} />;
}; };
const renderPage = (pageText, index)=>{ const renderPage = (pageText, index)=>{
@@ -149,7 +141,7 @@ const BrewRenderer = (props)=>{
renderedPages.length = 0; renderedPages.length = 0;
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first // 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)=>{ _.forEach(rawPages, (page, index)=>{
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){ if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
@@ -159,6 +151,16 @@ const BrewRenderer = (props)=>{
return renderedPages; return renderedPages;
}; };
const handleControlKeys = (e)=>{
if(!(e.ctrlKey || e.metaKey)) return;
const P_KEY = 80;
if(e.keyCode == P_KEY && props.allowPrint) printCurrentBrew();
if(e.keyCode == P_KEY) {
e.stopPropagation();
e.preventDefault();
}
};
const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount" const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount"
setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame
updateSize(); updateSize();
@@ -177,21 +179,33 @@ const BrewRenderer = (props)=>{
document.dispatchEvent(new MouseEvent('click')); document.dispatchEvent(new MouseEvent('click'));
}; };
const rendererPath = props.renderer == 'V3' ? 'V3' : 'Legacy'; //Toolbar settings:
const themePath = props.theme ?? '5ePHB'; const handleZoom = (newZoom)=>{
const baseThemePath = Themes[rendererPath][themePath].baseTheme; setState((prevState)=>({
...prevState,
zoom : newZoom
}));
};
return ( return (
<> <>
{/*render dummy page while iFrame is mounting.*/} {/*render dummy page while iFrame is mounting.*/}
{!state.isMounted {!state.isMounted
? <div className='brewRenderer' onScroll={handleScroll}> ? <div className='brewRenderer' onScroll={updateCurrentPage}>
<div className='pages'> <div className='pages'>
{renderDummyPage(1)} {renderDummyPage(1)}
</div> </div>
</div> </div>
: null} : null}
<ErrorBar errors={props.errors} />
<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.*/} {/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT} <Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
style={{ width: '100%', height: '100%', visibility: state.visibility }} style={{ width: '100%', height: '100%', visibility: state.visibility }}
@@ -199,33 +213,23 @@ const BrewRenderer = (props)=>{
onClick={()=>{emitClick();}} onClick={()=>{emitClick();}}
> >
<div className={'brewRenderer'} <div className={'brewRenderer'}
onScroll={handleScroll} onScroll={updateCurrentPage}
onKeyDown={handleControlKeys}
tabIndex={-1}
style={{ height: state.height }}> style={{ height: state.height }}>
<ErrorBar errors={props.errors} />
<div className='popups'>
<RenderWarnings />
<NotificationPopup />
</div>
<link href={`/themes/${rendererPath}/Blank/style.css`} type='text/css' rel='stylesheet'/>
{baseThemePath &&
<link href={`/themes/${rendererPath}/${baseThemePath}/style.css`} type='text/css' rel='stylesheet'/>
}
<link href={`/themes/${rendererPath}/${themePath}/style.css`} type='text/css' rel='stylesheet'/>
{/* Apply CSS from Style tab and render pages from Markdown tab */} {/* Apply CSS from Style tab and render pages from Markdown tab */}
{state.isMounted {state.isMounted
&& &&
<> <>
{renderStyle()} {renderStyle()}
<div className='pages' lang={`${props.lang || 'en'}`}> <div className='pages' lang={`${props.lang || 'en'}`} style={{ zoom: `${state.zoom}%` }}>
{renderPages()} {renderPages()}
</div> </div>
</> </>
} }
</div> </div>
</Frame> </Frame>
{renderPageInfo()}
</> </>
); );
}; };

View File

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

View File

@@ -1,80 +1,46 @@
require('./notificationPopup.less'); require('./notificationPopup.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash'); const _ = require('lodash');
const DISMISS_KEY = 'dismiss_notification12-04-23'; import Dialog from '../../../components/dialog.jsx';
const NotificationPopup = createClass({ const DISMISS_KEY = 'dismiss_notification04-09-24';
displayName : 'NotificationPopup', const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
getInitialState : function() {
return {
notifications : {}
};
},
componentDidMount : function() {
this.checkNotifications();
window.addEventListener('resize', this.checkNotifications);
},
componentWillUnmount : function() {
window.removeEventListener('resize', this.checkNotifications);
},
notifications : {
psa : function(){
return (
<>
<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>
<li key='googleDriveFolder'> const NotificationPopup = ()=>{
<em>Don't delete your Homebrewery folder on Google Drive!</em> <br /> return <Dialog className='notificationPopup' dismissKey={DISMISS_KEY} closeText={DISMISS_BUTTON} >
We have had several reports of users losing their brews, not realizing <div className='header'>
that they had deleted the files on their Google Drive. If you have a Homebrewery folder <i className='fas fa-info-circle info'></i>
on your Google Drive with *.txt files inside, <em>do not delete it</em>! <h3>Notice</h3>
We cannot help you recover files that you have deleted from your own <small>This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:</small>
Google Drive. </div>
</li> <ul>
<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.
<li key='faq'> More features will be coming.
<em>Protect your work! </em> <br /> </li>
If you opt not to use your Google Drive, keep in mind that we do not save a history of your projects. Please make frequent backups of your brews!&nbsp;
<a target='_blank' href='https://www.reddit.com/r/homebrewery/comments/adh6lh/faqs_psas_announcements/'>
See the FAQ
</a> to learn how to avoid losing your work!
</li>
</>
);
}
},
checkNotifications : function(){
const hideDismiss = localStorage.getItem(DISMISS_KEY);
if(hideDismiss) return this.setState({ notifications: {} });
this.setState({ <li key='googleDriveFolder'>
notifications : _.mapValues(this.notifications, (fn)=>{ return fn(); }) //Convert notification functions into their return text value <em>Don't delete your Homebrewery folder on Google Drive!</em> <br />
}); We have had several reports of users losing their brews, not realizing
}, that they had deleted the files on their Google Drive. If you have a Homebrewery folder
dismiss : function(){ on your Google Drive with *.txt files inside, <em>do not delete it</em>!
localStorage.setItem(DISMISS_KEY, true); We cannot help you recover files that you have deleted from your own
this.checkNotifications(); Google Drive.
}, </li>
render : function(){
if(_.isEmpty(this.state.notifications)) return null;
return <div className='notificationPopup'> <li key='faq'>
<i className='fas fa-times dismiss' onClick={this.dismiss}/> <em>Protect your work! </em> <br />
<i className='fas fa-info-circle info' /> If you opt not to use your Google Drive, keep in mind that we do not save a history of your projects. Please make frequent backups of your brews!&nbsp;
<div className='header'> <a target='_blank' href='https://www.reddit.com/r/homebrewery/comments/adh6lh/faqs_psas_announcements/'>
<h3>Notice</h3> See the FAQ
<small>This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:</small> </a> to learn how to avoid losing your work!
</div> </li>
<ul>{_.values(this.state.notifications)}</ul> </ul>
</div>; </Dialog>;
} };
});
module.exports = NotificationPopup; module.exports = NotificationPopup;

View File

@@ -1,64 +1,61 @@
.popups{ .popups {
position : fixed; position : fixed;
top : @navbarHeight; top : calc(@navbarHeight + @viewerToolsHeight);
right : 15px; right : 24px;
z-index : 10001; z-index : 10001;
width : 450px; width : 450px;
margin-top : 5px;
} }
.notificationPopup{ .notificationPopup {
position : relative; position : relative;
display : inline-block;
width : 100%; width : 100%;
padding : 15px; padding : 15px;
padding-bottom : 10px; padding-bottom : 10px;
padding-left : 25px; padding-left : 25px;
background-color : @blue;
color : white; color : white;
a{ background-color : @blue;
color : #e0e5c1; border : none;
&[open] { display : inline-block; }
a {
font-weight : 800; font-weight : 800;
color : #E0E5C1;
} }
i.info{ i.info {
position : absolute; position : absolute;
top : 12px; top : 12px;
left : 12px; left : 12px;
opacity : 0.8;
font-size : 2.5em; font-size : 2.5em;
opacity : 0.8;
} }
i.dismiss{ button.dismiss {
position : absolute; position : absolute;
top : 10px; top : 10px;
right : 10px; right : 10px;
cursor : pointer; cursor : pointer;
opacity : 0.6; background-color : transparent;
&:hover{ opacity : 0.6;
opacity : 1; &:hover { opacity : 1; }
}
} }
.header { .header { padding-left : 50px; }
padding-left : 50px; small {
}
small{
opacity : 0.7;
font-size : 0.6em; font-size : 0.6em;
opacity : 0.7;
} }
h3{ h3 {
font-size : 1.1em; font-size : 1.1em;
font-weight : 800; font-weight : 800;
} }
ul{ ul {
margin-top : 15px; margin-top : 15px;
font-size : 0.8em; font-size : 0.8em;
list-style-position : outside; list-style-position : outside;
list-style-type : disc; list-style-type : disc;
li{ li {
margin-top : 1.4em;
font-size : 0.8em; font-size : 0.8em;
line-height : 1.4em; line-height : 1.4em;
margin-top : 1.4em; em { font-weight : 800; }
em{
font-weight : 800;
}
} }
} }
} }

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'); require('./editor.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const _ = require('lodash'); const _ = require('lodash');
const cx = require('classnames');
const dedent = require('dedent-tabs').default; const dedent = require('dedent-tabs').default;
const Markdown = require('../../../shared/naturalcrit/markdown.js'); const Markdown = require('../../../shared/naturalcrit/markdown.js');
@@ -22,6 +21,7 @@ const DEFAULT_STYLE_TEXT = dedent`
color: black; color: black;
}`; }`;
let isJumping = false;
const Editor = createClass({ const Editor = createClass({
displayName : 'Editor', displayName : 'Editor',
@@ -37,8 +37,15 @@ const Editor = createClass({
onMetaChange : ()=>{}, onMetaChange : ()=>{},
reportError : ()=>{}, reportError : ()=>{},
onCursorPageChange : ()=>{},
onViewPageChange : ()=>{},
editorTheme : 'default', editorTheme : 'default',
renderer : 'legacy' renderer : 'legacy',
currentEditorCursorPageNum : 1,
currentEditorViewPageNum : 1,
currentBrewRendererPageNum : 1,
}; };
}, },
getInitialState : function() { getInitialState : function() {
@@ -56,9 +63,15 @@ const Editor = createClass({
isMeta : function() {return this.state.view == 'meta';}, isMeta : function() {return this.state.view == 'meta';},
componentDidMount : function() { componentDidMount : function() {
this.updateEditorSize(); this.updateEditorSize();
this.highlightCustomMarkdown(); this.highlightCustomMarkdown();
window.addEventListener('resize', this.updateEditorSize); 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); const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
if(editorTheme) { if(editorTheme) {
@@ -73,13 +86,35 @@ const Editor = createClass({
}, },
componentDidUpdate : function(prevProps, prevState, snapshot) { componentDidUpdate : function(prevProps, prevState, snapshot) {
this.highlightCustomMarkdown(); this.highlightCustomMarkdown();
if(prevProps.moveBrew !== this.props.moveBrew) { if(prevProps.moveBrew !== this.props.moveBrew)
this.brewJump(); this.brewJump();
};
if(prevProps.moveSource !== this.props.moveSource) { if(prevProps.moveSource !== this.props.moveSource)
this.sourceJump(); 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() { 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){ handleInject : function(injectText){
this.codeEditor.current?.injectText(injectText, false); this.codeEditor.current?.injectText(injectText, false);
}, },
@@ -98,19 +147,10 @@ const Editor = createClass({
this.props.setMoveArrows(newView === 'text'); this.props.setMoveArrows(newView === 'text');
this.setState({ this.setState({
view : newView view : newView
}, this.updateEditorSize); //TODO: not sure if updateeditorsize needed }, ()=>{
}, this.codeEditor.current?.codeMirror.focus();
this.updateEditorSize();
getCurrentPage : function(){ }); //TODO: not sure if updateeditorsize needed
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);
}, },
highlightCustomMarkdown : function(){ highlightCustomMarkdown : function(){
@@ -119,8 +159,19 @@ const Editor = createClass({
const codeMirror = this.codeEditor.current.codeMirror; const codeMirror = this.codeEditor.current.codeMirror;
codeMirror.operation(()=>{ // Batch CodeMirror styling codeMirror.operation(()=>{ // Batch CodeMirror styling
const foldLines = [];
//reset custom text styles //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(); for (let i=customHighlights.length - 1;i>=0;i--) customHighlights[i].clear();
let editorPageCount = 2; // start page count from page 2 let editorPageCount = 2; // start page count from page 2
@@ -132,6 +183,11 @@ const Editor = createClass({
codeMirror.removeLineClass(lineNumber, 'text'); codeMirror.removeLineClass(lineNumber, 'text');
codeMirror.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash'); 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 // Styling for \page breaks
if((this.props.renderer == 'legacy' && line.includes('\\page')) || if((this.props.renderer == 'legacy' && line.includes('\\page')) ||
(this.props.renderer == 'V3' && line.match(/^\\page$/))) { (this.props.renderer == 'V3' && line.match(/^\\page$/))) {
@@ -155,7 +211,7 @@ const Editor = createClass({
// definition lists // definition lists
if(line.includes('::')){ 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. const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/ymd; // the `d` flag, for match indices, throws an ESLint error.
let match; let match;
while ((match = regex.exec(line)) != null){ 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[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' }); codeMirror.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
const ddIndex = match.indices[2][0]; const ddIndex = match.indices[2][0];
let colons = /::/g; const colons = /::/g;
let colonMatches = colons.exec(match[2]); const colonMatches = colons.exec(match[2]);
if(colonMatches !== null){ 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('^'); let startIndex = line.indexOf('^');
const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy; const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy;
const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy; const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy;
while (startIndex >= 0) { while (startIndex >= 0) {
superRegex.lastIndex = subRegex.lastIndex = startIndex; superRegex.lastIndex = subRegex.lastIndex = startIndex;
let isSuper = false; let isSuper = false;
let match = subRegex.exec(line) || superRegex.exec(line); const match = subRegex.exec(line) || superRegex.exec(line);
if (match) { if(match) {
isSuper = !subRegex.lastIndex; isSuper = !subRegex.lastIndex;
codeMirror.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' }); 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) { while (startIndex >= 0) {
emojiRegex.lastIndex = startIndex; emojiRegex.lastIndex = startIndex;
let match = emojiRegex.exec(line); const match = emojiRegex.exec(line);
if (match) { if(match) {
let tokens = Markdown.marked.lexer(match[0]); let tokens = Markdown.marked.lexer(match[0]);
tokens = tokens[0].tokens.filter(t => t.type == 'emoji') tokens = tokens[0].tokens.filter((t)=>t.type == 'emoji');
if (!tokens.length) if(!tokens.length)
return; return;
let startPos = { line: lineNumber, ch: match.index }; const startPos = { line: lineNumber, ch: match.index };
let endPos = { line: lineNumber, ch: match.index + match[0].length }; const endPos = { line: lineNumber, ch: match.index + match[0].length };
// Iterate over conflicting marks and clear them // Iterate over conflicting marks and clear them
var marks = codeMirror.findMarks(startPos, endPos); const marks = codeMirror.findMarks(startPos, endPos);
marks.forEach(function(marker) { marks.forEach(function(marker) {
marker.clear(); if(!marker.__isFold) marker.clear();
}); });
codeMirror.markText(startPos, endPos, { className: 'emoji' }); codeMirror.markText(startPos, endPos, { className: 'emoji' });
} }
@@ -257,75 +313,93 @@ const Editor = createClass({
} }
}, },
brewJump : function(targetPage=this.getCurrentPage()){ brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
if(!window) return; if(!window || isJumping)
// console.log(`Scroll to: p${targetPage}`); return;
// Get current brewRenderer scroll position and calculate target position
const brewRenderer = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0]; const brewRenderer = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
const currentPos = brewRenderer.scrollTop; const currentPos = brewRenderer.scrollTop;
const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top; const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top;
const interimPos = targetPos >= 0 ? -30 : 30;
const bounceDelay = 100; const checkIfScrollComplete = ()=>{
const scrollDelay = 500; let scrollingTimeout;
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
if(!this.throttleBrewMove) { scrollingTimeout = setTimeout(()=>{
this.throttleBrewMove = _.throttle((currentPos, interimPos, targetPos)=>{ isJumping = false;
brewRenderer.scrollTo({ top: currentPos + interimPos, behavior: 'smooth' }); brewRenderer.removeEventListener('scroll', checkIfScrollComplete);
setTimeout(()=>{ }, 150); // If 150 ms pass without a brewRenderer scroll event, assume scrolling is done
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' });
}, bounceDelay);
}, scrollDelay, { leading: true, trailing: false });
}; };
this.throttleBrewMove(currentPos, interimPos, targetPos);
// const hashPage = (page != 1) ? `p${page}` : ''; isJumping = true;
// window.location.hash = hashPage; 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){ sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
if(this.isText()) { if(!this.isText || isJumping)
if(targetLine == null) { return;
targetLine = 0;
const pageCollection = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('page'); const textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\page/;
const brewRendererHeight = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer').item(0).getBoundingClientRect().height; 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; let currentY = this.codeEditor.current.codeMirror.getScrollInfo().top;
for (const page of pageCollection) { let targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
if(page.getBoundingClientRect().bottom > (brewRendererHeight / 2)) {
currentPage = parseInt(page.id.slice(1)) || 1; const checkIfScrollComplete = ()=>{
break; 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);
} }
}, 10);
const textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\page/; } else {
const textString = this.props.brew.text.split(textSplit).slice(0, currentPage-1).join(textSplit); this.codeEditor.current.codeMirror.scrollTo(null, targetY); // Scroll any remaining difference
const textPosition = textString.length; this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
const lineCount = textString.match('\n') ? textString.slice(0, textPosition).split('\n').length : 0; this.codeEditor.current.codeMirror.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
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);
}
} }
}, },
@@ -367,7 +441,7 @@ const Editor = createClass({
view={this.state.view} view={this.state.view}
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT} value={this.props.brew.style ?? DEFAULT_STYLE_TEXT}
onChange={this.props.onStyleChange} onChange={this.props.onStyleChange}
enableFolding={false} enableFolding={true}
editorTheme={this.state.editorTheme} editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent} /> rerenderParent={this.rerenderParent} />
</>; </>;
@@ -381,7 +455,8 @@ const Editor = createClass({
<MetadataEditor <MetadataEditor
metadata={this.props.brew} metadata={this.props.brew}
onChange={this.props.onMetaChange} onChange={this.props.onMetaChange}
reportError={this.props.reportError}/> reportError={this.props.reportError}
userThemes={this.props.userThemes}/>
</>; </>;
} }
}, },
@@ -424,7 +499,10 @@ const Editor = createClass({
historySize={this.historySize()} historySize={this.historySize()}
currentEditorTheme={this.state.editorTheme} currentEditorTheme={this.state.editorTheme}
updateEditorTheme={this.updateEditorTheme} updateEditorTheme={this.updateEditorTheme}
cursorPos={this.codeEditor.current?.getCursorPosition() || {}} /> snippetBundle={this.props.snippetBundle}
cursorPos={this.codeEditor.current?.getCursorPosition() || {}}
updateBrew={this.props.updateBrew}
/>
{this.renderEditor()} {this.renderEditor()}
</div> </div>

View File

@@ -8,6 +8,7 @@ const Nav = require('naturalcrit/nav/nav.jsx');
const Combobox = require('client/components/combobox.jsx'); const Combobox = require('client/components/combobox.jsx');
const StringArrayEditor = require('../stringArrayEditor/stringArrayEditor.jsx'); const StringArrayEditor = require('../stringArrayEditor/stringArrayEditor.jsx');
const Themes = require('themes/themes.json'); const Themes = require('themes/themes.json');
const validations = require('./validations.js'); const validations = require('./validations.js');
@@ -27,6 +28,7 @@ const MetadataEditor = createClass({
return { return {
metadata : { metadata : {
editId : null, editId : null,
shareId : null,
title : '', title : '',
description : '', description : '',
thumbnail : '', thumbnail : '',
@@ -98,7 +100,7 @@ const MetadataEditor = createClass({
if(renderer == 'legacy') if(renderer == 'legacy')
this.props.metadata.theme = '5ePHB'; this.props.metadata.theme = '5ePHB';
} }
this.props.onChange(this.props.metadata); this.props.onChange(this.props.metadata, 'renderer');
}, },
handlePublish : function(val){ handlePublish : function(val){
this.props.onChange({ this.props.onChange({
@@ -110,7 +112,7 @@ const MetadataEditor = createClass({
handleTheme : function(theme){ handleTheme : function(theme){
this.props.metadata.renderer = theme.renderer; this.props.metadata.renderer = theme.renderer;
this.props.metadata.theme = theme.path; this.props.metadata.theme = theme.path;
this.props.onChange(this.props.metadata); this.props.onChange(this.props.metadata, 'theme');
}, },
handleLanguage : function(languageCode){ handleLanguage : function(languageCode){
@@ -191,37 +193,42 @@ const MetadataEditor = createClass({
renderThemeDropdown : function(){ renderThemeDropdown : function(){
if(!global.enable_themes) return; if(!global.enable_themes) return;
const mergedThemes = _.merge(Themes, this.props.userThemes);
const listThemes = (renderer)=>{ const listThemes = (renderer)=>{
return _.map(_.values(Themes[renderer]), (theme)=>{ return _.map(_.values(mergedThemes[renderer]), (theme)=>{
return <div className='item' key={''} onClick={()=>this.handleTheme(theme)} title={''}> if(theme.path == this.props.metadata.shareId) return;
{`${theme.renderer} : ${theme.name}`} const preview = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownPreview.png`;
<img src={`/themes/${theme.renderer}/${theme.path}/dropdownTexture.png`}/> const texture = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownTexture.png`;
return <div className='item' key={`${renderer}_${theme.name}`} onClick={()=>this.handleTheme(theme)} title={''}>
{theme.author ?? renderer} : {theme.name}
<div className='texture-container'>
<img src={texture}/>
</div>
<div className='preview'> <div className='preview'>
<h6>{`${theme.name}`} preview</h6> <h6>{theme.name} preview</h6>
<img src={`/themes/${theme.renderer}/${theme.path}/dropdownPreview.png`}/> <img src={preview}/>
</div> </div>
</div>; </div>;
}); });
}; };
const currentTheme = Themes[`${_.upperFirst(this.props.metadata.renderer)}`][this.props.metadata.theme]; const currentRenderer = this.props.metadata.renderer;
const currentTheme = mergedThemes[`${_.upperFirst(this.props.metadata.renderer)}`][this.props.metadata.theme]
?? { name: `!!! THEME MISSING !!! ID=${this.props.metadata.theme}` };
let dropdown; let dropdown;
if(this.props.metadata.renderer == 'legacy') { if(currentRenderer == 'legacy') {
dropdown = dropdown =
<Nav.dropdown className='disabled value' trigger='disabled'> <Nav.dropdown className='disabled value' trigger='disabled'>
<div> <div> {`Themes are not supported in the Legacy Renderer`} <i className='fas fa-caret-down'></i> </div>
{`Themes are not supported in the Legacy Renderer`} <i className='fas fa-caret-down'></i>
</div>
</Nav.dropdown>; </Nav.dropdown>;
} else { } else {
dropdown = dropdown =
<Nav.dropdown className='value' trigger='click'> <Nav.dropdown className='value' trigger='click'>
<div> <div> {currentTheme.author ?? _.upperFirst(currentRenderer)} : {currentTheme.name} <i className='fas fa-caret-down'></i> </div>
{`${_.upperFirst(currentTheme.renderer)} : ${currentTheme.name}`} <i className='fas fa-caret-down'></i>
</div> {listThemes(currentRenderer)}
{/*listThemes('Legacy')*/}
{listThemes('V3')}
</Nav.dropdown>; </Nav.dropdown>;
} }

View File

@@ -1,327 +1,309 @@
@import 'naturalcrit/styles/colors.less'; @import 'naturalcrit/styles/colors.less';
.metadataEditor{ .metadataEditor {
position : absolute; position : absolute;
z-index : 5; z-index : 5;
box-sizing : border-box; box-sizing : border-box;
width : 100%; width : 100%;
padding : 25px;
background-color : #999;
height : calc(100vh - 54px); // 54px is the height of the navbar + snippet bar. probably a better way to dynamic get this. height : calc(100vh - 54px); // 54px is the height of the navbar + snippet bar. probably a better way to dynamic get this.
padding : 25px;
overflow-y : auto; overflow-y : auto;
background-color : #999999;
.sectionHead { .sectionHead {
font-weight: 1000; margin : 20px 0;
margin: 20px 0; font-weight : 1000;
&:first-of-type { &:first-of-type { margin-top : 0; }
margin-top: 0;
}
} }
& > div { & > div { margin-bottom : 10px; }
margin-bottom: 10px;
}
.field-group { .field-group {
display: flex; display : flex;
width: 100%; flex-wrap : wrap;
flex-wrap: wrap; gap : 10px;
gap: 10px; width : 100%;
} }
.field-column { .field-column {
display: flex; display : flex;
flex-direction: column; flex : 5 0 200px;
flex: 5 0 200px; flex-direction : column;
gap: 10px; gap : 10px;
} }
.field{ .field {
position : relative;
display : flex; display : flex;
flex-wrap : wrap; flex-wrap : wrap;
width : 100%; width : 100%;
min-width : 200px; min-width : 200px;
position : relative; & > label {
&>label{
width : 80px; width : 80px;
font-size : 11px; font-size : 11px;
font-weight : 800; font-weight : 800;
line-height : 1.8em; line-height : 1.8em;
text-transform : uppercase; text-transform : uppercase;
} }
&>.value{ & > .value {
flex : 1 1 auto; flex : 1 1 auto;
width : 50px; width : 50px;
&:invalid { &:invalid { background : #FFB9B9; }
background : #ffb9b9;
}
} }
input[type='text'], textarea { input[type='text'], textarea {
border : 1px solid gray; border : 1px solid gray;
&:focus { &:focus { outline : 1px solid #444444; }
outline: 1px solid #444;
}
} }
&.thumbnail{ &.thumbnail {
height : 1.4em; height : 1.4em;
label{ label { line-height : 2.0em; }
line-height: 2.0em; .value {
overflow : hidden;
text-overflow : ellipsis;
} }
.value{ button {
overflow: hidden; padding : 0px 5px;
text-overflow: ellipsis; color : white;
} background-color : black;
button{ border : 1px solid #999999;
border: 1px solid #999; &:hover { background-color : #777777; }
color: white;
padding: 0px 5px;
background-color: black;
&:hover{
background-color: #777;
}
} }
} }
&.description { &.description {
flex: 1; flex : 1;
textarea.value { textarea.value {
resize : none;
height : auto; height : auto;
font-family : 'Open Sans', sans-serif; font-family : 'Open Sans', sans-serif;
font-size : 0.8em; font-size : 0.8em;
resize : none;
} }
} }
&.language .language-dropdown { &.language .language-dropdown {
max-width : 150px;
z-index : 200; z-index : 200;
max-width : 150px;
} }
small { small {
display : inline-block;
font-size : 0.6em; font-size : 0.6em;
font-style : italic; font-style : italic;
line-height : 1.4em; line-height : 1.4em;
display : inline-block;
} }
} }
.thumbnail-preview { .thumbnail-preview {
position: relative; position : relative;
justify-self: center; flex : 1 1;
width: 80px; justify-self : center;
height: min-content; width : 80px;
flex: 1 1; height : min-content;
max-height: 115px; max-height : 115px;
aspect-ratio: 1 / 1; aspect-ratio : 1 / 1;
object-fit: contain; object-fit : contain;
background-color: #AAA; background-color : #AAAAAA;
} }
.systems.field .value{ .systems.field .value {
label{ label {
vertical-align : middle;
margin-right : 15px;
cursor : pointer;
font-size : 0.7em;
font-weight : 800;
user-select : none;
white-space : nowrap;
display : inline-flex; display : inline-flex;
align-items : center; align-items : center;
} margin-right : 15px;
a { font-size : 0.7em;
font-size : 0.7em; font-weight : 800;
font-weight : 800; white-space : nowrap;
display : inline-flex;
}
input{
vertical-align : middle; vertical-align : middle;
cursor : pointer; cursor : pointer;
user-select : none;
}
a {
display : inline-flex;
font-size : 0.7em;
font-weight : 800;
}
input {
margin : 3px; margin : 3px;
vertical-align : middle;
cursor : pointer;
} }
} }
.publish.field .value{ .publish.field .value {
position : relative; position : relative;
margin-bottom: 15px; margin-bottom : 15px;
button{ button { width : 100%; }
width:100%; button.publish {
}
button.publish{
.button(@blueLight); .button(@blueLight);
} }
button.unpublish{ button.unpublish {
.button(@silver); .button(@silver);
} }
} }
.delete.field .value{ .delete.field .value {
button{ button {
.button(@red); .button(@red);
} }
} }
.authors.field .value{ .authors.field .value {
font-size: 0.8em; font-size : 0.8em;
line-height : 1.5em; line-height : 1.5em;
} }
.themes.field{ .themes.field {
font-size : 13.33px; font-size : 13.33px;
.navDropdownContainer { .navDropdownContainer {
background-color : white; position : relative;
position : relative; z-index : 100;
z-index : 100; background-color : white;
&.disabled { &.disabled {
font-style :italic; font-style : italic;
font-style : italic; color : dimgray;
background-color : darkgray; background-color : darkgray;
color : dimgray;
} }
&>div:first-child { & > div:first-child {
border : 2px solid rgb(118,118,118); padding : 6px 3px;
padding : 6px 3px; background-color : inherit;
background-color : inherit; border : 2px solid rgb(118,118,118);
i { i { float : right; }
float : right;
}
&:hover { &:hover {
background-color : @blue; color : white;
color : white; background-color : @blue;
} }
} }
.navDropdown .item > p {
width : 45%;
height : 1.1em;
overflow : hidden;
text-overflow : ellipsis;
white-space : nowrap;
}
.navDropdown { .navDropdown {
box-shadow : 0px 5px 10px rgba(0, 0, 0, 0.3);
position : absolute; position : absolute;
width : 100%; width : 100%;
box-shadow : 0px 5px 10px rgba(0, 0, 0, 0.3);
.item { .item {
padding : 3px 3px;
border-top : 1px solid rgb(118, 118, 118);
position : relative; position : relative;
padding : 3px 3px;
overflow : visible; overflow : visible;
background-color : white; background-color : white;
border-top : 1px solid rgb(118, 118, 118);
.preview { .preview {
display : flex; position : absolute;
flex-direction: column; top : 0;
background : #ccc; right : 0;
border-radius : 5px; z-index : 1;
box-shadow : 0 0 5px black; display : flex;
width : 200px; flex-direction : column;
color :black; width : 200px;
position : absolute; overflow : hidden;
top : 0; color : black;
right : 0; background : #CCCCCC;
opacity : 0; border-radius : 5px;
transition : opacity 250ms ease; box-shadow : 0 0 5px black;
z-index : 1; opacity : 0;
overflow :hidden; transition : opacity 250ms ease;
h6 { h6 {
font-weight : 900; padding-block : 0.5em;
padding-inline:1em; padding-inline : 1em;
padding-block :.5em; font-weight : 900;
border-bottom :2px solid hsl(0,0%,40%); border-bottom : 2px solid hsl(0,0%,40%);
} }
} }
&:hover { &:hover {
background-color : @blue; color : white;
color : white; background-color : @blue;
} }
&:hover > .preview { &:hover > .preview { opacity : 1; }
opacity: 1; .texture-container {
} position : absolute;
>img { top : 0;
mask-image : linear-gradient(90deg, transparent, black 20%); left : 0;
-webkit-mask-image : linear-gradient(90deg, transparent, black 20%); width : 100%;
position : absolute; height : 100%;
right : 0; min-height : 100%;
top : 0px; overflow : hidden;
width : 50%; > img {
height : 100%; position : absolute;
top : 0px;
right : 0;
width : 50%;
min-height : 100%;
-webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
mask-image : linear-gradient(90deg, transparent, black 20%);
}
} }
} }
} }
} }
} }
.field .list { .field .list {
display: flex; display : flex;
flex: 1 0; flex : 1 0;
flex-wrap: wrap; flex-wrap : wrap;
> * { > * { flex : 0 0 auto; }
flex: 0 0 auto;
}
#groupedIcon { #groupedIcon {
#backgroundColors; #backgroundColors;
display: inline-block; position : relative;
height: ~"calc(100% + 0.6em)"; top : -0.3em;
position: relative; right : -0.3em;
top: -0.3em; display : inline-block;
right: -0.3em; min-width : 20px;
cursor: pointer; height : ~'calc(100% + 0.6em)';
min-width: 20px; color : white;
text-align: center; text-align : center;
color: white; cursor : pointer;
i { i {
position: relative; position : relative;
top: 50%; top : 50%;
transform: translateY(-50%); transform : translateY(-50%);
} }
&:not(:last-child) { &:not(:last-child) { border-right : 1px solid black; }
border-right: 1px solid black;
}
&:last-child { &:last-child { border-radius : 0 0.5em 0.5em 0; }
border-radius: 0 0.5em 0.5em 0;
}
} }
.badge { .badge {
background-color: #dddddd; padding : 0.3em;
border-radius: .5em; margin : 2px;
font-size: .9em; font-size : 0.9em;
margin: 2px; background-color : #DDDDDD;
padding: .3em; border-radius : 0.5em;
.icon { .icon {
#groupedIcon #groupedIcon; }
}
} }
.input-group { .input-group {
height: ~"calc(.9em + 4px + .6em)"; height : ~'calc(.9em + 4px + .6em)';
input { input { border-radius : 0.5em 0 0 0.5em; }
border-radius: .5em 0 0 .5em;
}
input:last-child { input:last-child { border-radius : 0.5em; }
border-radius: .5em;
}
.value { .value {
width: 7.5vw; width : 7.5vw;
min-width: 75px; min-width : 75px;
height: 100%; height : 100%;
} }
.invalid:focus { .invalid:focus { background-color : pink; }
background-color: pink;
}
.icon { .icon {
#groupedIcon; #groupedIcon;
height: 97%; top : -0.54em;
font-size: .8em; right : 1px;
right: 1px; height : 97%;
top: -.54em; font-size : 0.8em;
i { i { font-size : 1.125em; }
font-size: 1.125em;
}
} }
} }
} }

View File

@@ -5,10 +5,9 @@ const createClass = require('create-react-class');
const _ = require('lodash'); const _ = require('lodash');
const cx = require('classnames'); const cx = require('classnames');
import { getHistoryItems, historyExists } from '../../utils/versionHistory.js';
//Import all themes //Import all themes
const Themes = require('themes/themes.json');
const ThemeSnippets = {}; const ThemeSnippets = {};
ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js'); ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js');
ThemeSnippets['V3_5ePHB'] = require('themes/V3/5ePHB/snippets.js'); ThemeSnippets['V3_5ePHB'] = require('themes/V3/5ePHB/snippets.js');
@@ -40,7 +39,9 @@ const Snippetbar = createClass({
foldCode : ()=>{}, foldCode : ()=>{},
unfoldCode : ()=>{}, unfoldCode : ()=>{},
updateEditorTheme : ()=>{}, updateEditorTheme : ()=>{},
cursorPos : {} cursorPos : {},
snippetBundle : [],
updateBrew : ()=>{}
}; };
}, },
@@ -48,53 +49,50 @@ const Snippetbar = createClass({
return { return {
renderer : this.props.renderer, renderer : this.props.renderer,
themeSelector : false, themeSelector : false,
snippets : [] snippets : [],
historyExists : false
}; };
}, },
componentDidMount : async function() { componentDidMount : async function() {
const rendererPath = this.props.renderer == 'V3' ? 'V3' : 'Legacy'; const snippets = this.compileSnippets();
const themePath = this.props.theme ?? '5ePHB';
let snippets = _.cloneDeep(ThemeSnippets[`${rendererPath}_${themePath}`]);
snippets = this.compileSnippets(rendererPath, themePath, snippets);
this.setState({ this.setState({
snippets : snippets snippets : snippets
}); });
}, },
componentDidUpdate : async function(prevProps) { componentDidUpdate : async function(prevProps) {
if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme) { if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) {
const rendererPath = this.props.renderer == 'V3' ? 'V3' : 'Legacy';
const themePath = this.props.theme ?? '5ePHB';
let snippets = _.cloneDeep(ThemeSnippets[`${rendererPath}_${themePath}`]);
snippets = this.compileSnippets(rendererPath, themePath, snippets);
this.setState({ this.setState({
snippets : snippets snippets : this.compileSnippets()
}); });
} };
this.setState({
historyExists : historyExists(this.props.brew)
});
}, },
mergeCustomizer : function(oldValue, newValue, key) {
mergeCustomizer : function(valueA, valueB, key) {
if(key == 'snippets') { if(key == 'snippets') {
const result = _.reverse(_.unionBy(_.reverse(valueB), _.reverse(valueA), 'name')); // Join snippets together, with preference for the current theme over the base theme 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);
} }
}, },
compileSnippets : function(rendererPath, themePath, snippets) { compileSnippets : function() {
let compiledSnippets = snippets; let compiledSnippets = [];
const baseSnippetsPath = Themes[rendererPath][themePath].baseSnippets;
const objB = _.keyBy(compiledSnippets, 'groupName'); let oldSnippets = _.keyBy(compiledSnippets, 'groupName');
if(baseSnippetsPath) { for (let snippets of this.props.snippetBundle) {
const objA = _.keyBy(_.cloneDeep(ThemeSnippets[`${rendererPath}_${baseSnippetsPath}`]), 'groupName'); if(typeof(snippets) == 'string') // load staticThemes as needed; they were sent as just a file name
compiledSnippets = _.values(_.mergeWith(objA, objB, this.mergeCustomizer)); snippets = ThemeSnippets[snippets];
compiledSnippets = this.compileSnippets(rendererPath, baseSnippetsPath, _.cloneDeep(compiledSnippets));
} else { const newSnippets = _.keyBy(_.cloneDeep(snippets), 'groupName');
const objA = _.keyBy(_.cloneDeep(ThemeSnippets[`${rendererPath}_Blank`]), 'groupName'); compiledSnippets = _.values(_.mergeWith(oldSnippets, newSnippets, this.mergeCustomizer));
compiledSnippets = _.values(_.mergeWith(objA, objB, this.mergeCustomizer));
oldSnippets = _.keyBy(compiledSnippets, 'groupName');
} }
return compiledSnippets; return compiledSnippets;
}, },
@@ -146,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(){ renderEditorButtons : function(){
if(!this.props.showEditButtons) return; if(!this.props.showEditButtons) return;
@@ -166,6 +194,10 @@ const Snippetbar = createClass({
} }
return <div className='editors'> 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' : ''}`} <div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
onClick={this.props.undo} > onClick={this.props.undo} >
<i className='fas fa-undo' /> <i className='fas fa-undo' />

View File

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

View File

@@ -10,7 +10,7 @@ const UserPage = require('./pages/userPage/userPage.jsx');
const SharePage = require('./pages/sharePage/sharePage.jsx'); const SharePage = require('./pages/sharePage/sharePage.jsx');
const NewPage = require('./pages/newPage/newPage.jsx'); const NewPage = require('./pages/newPage/newPage.jsx');
const ErrorPage = require('./pages/errorPage/errorPage.jsx'); const ErrorPage = require('./pages/errorPage/errorPage.jsx');
const PrintPage = require('./pages/printPage/printPage.jsx'); const VaultPage = require('./pages/vaultPage/vaultPage.jsx');
const AccountPage = require('./pages/accountPage/accountPage.jsx'); const AccountPage = require('./pages/accountPage/accountPage.jsx');
const WithRoute = (props)=>{ const WithRoute = (props)=>{
@@ -67,20 +67,20 @@ const Homebrew = createClass({
<Router location={this.props.url}> <Router location={this.props.url}>
<div className='homebrew'> <div className='homebrew'>
<Routes> <Routes>
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={this.props.brew} />} /> <Route path='/edit/:id' element={<WithRoute el={EditPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} />
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={this.props.brew} />} /> <Route path='/share/:id' element={<WithRoute el={SharePage} brew={this.props.brew} />} />
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={this.props.brew} />} /> <Route path='/new/:id' element={<WithRoute el={NewPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} />
<Route path='/new' element={<WithRoute el={NewPage}/>} /> <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='/user/:username' element={<WithRoute el={UserPage} brews={this.props.brews} />} />
<Route path='/print/:id' element={<WithRoute el={PrintPage} brew={this.props.brew} />} /> <Route path='/vault' element={<WithRoute el={VaultPage}/>}/>
<Route path='/print' element={<WithRoute el={PrintPage} />} /> <Route path='/changelog' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
<Route path='/changelog' element={<WithRoute el={SharePage} brew={this.props.brew} />} /> <Route path='/faq' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
<Route path='/faq' element={<WithRoute el={SharePage} brew={this.props.brew} />} /> <Route path='/migrate' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
<Route path='/account' element={<WithRoute el={AccountPage} brew={this.props.brew} accountDetails={this.props.brew.accountDetails} />} /> <Route path='/account' element={<WithRoute el={AccountPage} brew={this.props.brew} accountDetails={this.props.brew.accountDetails} />} />
<Route path='/legacy' element={<WithRoute el={HomePage} brew={this.props.brew} />} /> <Route path='/legacy' element={<WithRoute el={HomePage} brew={this.props.brew} />} />
<Route path='/error' element={<WithRoute el={ErrorPage} brew={this.props.brew} />} /> <Route path='/error' element={<WithRoute el={ErrorPage} brew={this.props.brew} />} />
<Route path='/' element={<WithRoute el={HomePage} brew={this.props.brew} />} /> <Route path='/' element={<WithRoute el={HomePage} brew={this.props.brew} />} />
<Route path='/*' element={<WithRoute el={HomePage} brew={this.props.brew} />} /> <Route path='/*' element={<WithRoute el={HomePage} brew={this.props.brew} />} />
</Routes> </Routes>
</div> </div>
</Router> </Router>
@@ -88,15 +88,4 @@ const Homebrew = createClass({
} }
}); });
module.exports = Homebrew; module.exports = Homebrew;
//TODO: Nicer Error page instead of just "cant get that"
// '/share/:id' : (args)=>{
// if(!this.props.brew.shareId){
// return <ErrorPage errorId={args.id}/>;
// }
//
// return <SharePage
// id={args.id}
// brew={this.props.brew} />;
// },

View File

@@ -104,6 +104,18 @@ const ErrorNavItem = createClass({
</Nav.item>; </Nav.item>;
} }
if(HBErrorCode === '09') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
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!
</div>
</Nav.item>;
}
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'> return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops! Oops!
<div className='errorContainer'> <div className='errorContainer'>

View File

@@ -21,6 +21,9 @@
font-size : 10px; font-size : 10px;
font-weight : 800; font-weight : 800;
text-transform : uppercase; text-transform : uppercase;
.lowercase {
text-transform : none;
}
a{ a{
color : @teal; color : @teal;
} }

View File

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

View File

@@ -1,8 +1,9 @@
const React = require('react'); const React = require('react');
const Nav = require('naturalcrit/nav/nav.jsx'); const Nav = require('naturalcrit/nav/nav.jsx');
const { printCurrentBrew } = require('../../../shared/helpers.js');
module.exports = function(props){ module.exports = function(){
return <Nav.item newTab={true} href={`/print/${props.shareId}?dialog=true`} color='purple' icon='far fa-file-pdf'> return <Nav.item onClick={printCurrentBrew} color='purple' icon='far fa-file-pdf'>
get PDF get PDF
</Nav.item>; </Nav.item>;
}; };

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

View File

@@ -119,11 +119,12 @@
text-align : center; text-align : center;
a{ a{
.animate(opacity); .animate(opacity);
display : block; display : block;
margin : 8px 0px; margin : 8px 0px;
opacity : 0.6; opacity : 0.6;
font-size : 1.3em; font-size : 1.3em;
color : white; color : white;
text-decoration : unset;
&:hover{ &:hover{
opacity : 1; opacity : 1;
} }

View File

@@ -1,8 +1,9 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
require('./editPage.less'); require('./editPage.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash'); const _ = require('lodash');
const createClass = require('create-react-class');
const request = require('../../utils/request-middleware.js'); const request = require('../../utils/request-middleware.js');
const { Meta } = require('vitreum/headtags'); const { Meta } = require('vitreum/headtags');
@@ -11,7 +12,7 @@ const Navbar = require('../../navbar/navbar.jsx');
const NewBrew = require('../../navbar/newbrew.navitem.jsx'); const NewBrew = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx'); const HelpNavItem = require('../../navbar/help.navitem.jsx');
const PrintLink = require('../../navbar/print.navitem.jsx'); const PrintNavItem = require('../../navbar/print.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx'); const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const Account = require('../../navbar/account.navitem.jsx'); const Account = require('../../navbar/account.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both; const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
@@ -20,9 +21,14 @@ const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
const Editor = require('../../editor/editor.jsx'); const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx'); const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
const LockNotification = require('./lockNotification/lockNotification.jsx');
const Markdown = require('naturalcrit/markdown.js'); const Markdown = require('naturalcrit/markdown.js');
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.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 googleDriveIcon = require('../../googleDrive.svg');
@@ -38,20 +44,24 @@ const EditPage = createClass({
getInitialState : function() { getInitialState : function() {
return { return {
brew : this.props.brew, brew : this.props.brew,
isSaving : false, isSaving : false,
isPending : false, isPending : false,
alertTrashedGoogleBrew : this.props.brew.trashed, alertTrashedGoogleBrew : this.props.brew.trashed,
alertLoginToTransfer : false, alertLoginToTransfer : false,
saveGoogle : this.props.brew.googleId ? true : false, saveGoogle : this.props.brew.googleId ? true : false,
confirmGoogleTransfer : false, confirmGoogleTransfer : false,
error : null, error : null,
htmlErrors : Markdown.validate(this.props.brew.text), htmlErrors : Markdown.validate(this.props.brew.text),
url : '', url : '',
autoSave : true, autoSave : true,
autoSaveWarning : false, autoSaveWarning : false,
unsavedTime : new Date(), unsavedTime : new Date(),
currentEditorPage : 0 currentEditorViewPageNum : 1,
currentEditorCursorPageNum : 1,
currentBrewRendererPageNum : 1,
displayLockMessage : this.props.brew.lock || false,
themeBundle : {}
}; };
}, },
@@ -83,6 +93,8 @@ const EditPage = createClass({
htmlErrors : Markdown.validate(prevState.brew.text) htmlErrors : Markdown.validate(prevState.brew.text)
})); }));
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
document.addEventListener('keydown', this.handleControlKeys); document.addEventListener('keydown', this.handleControlKeys);
}, },
componentWillUnmount : function() { componentWillUnmount : function() {
@@ -95,7 +107,7 @@ const EditPage = createClass({
const S_KEY = 83; const S_KEY = 83;
const P_KEY = 80; const P_KEY = 80;
if(e.keyCode == S_KEY) this.trySave(true); if(e.keyCode == S_KEY) this.trySave(true);
if(e.keyCode == P_KEY) window.open(`/print/${this.processShareId()}?dialog=true`, '_blank').focus(); if(e.keyCode == P_KEY) printCurrentBrew();
if(e.keyCode == P_KEY || e.keyCode == S_KEY){ if(e.keyCode == P_KEY || e.keyCode == S_KEY){
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@@ -106,16 +118,27 @@ const EditPage = createClass({
this.editor.current.update(); 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){ handleTextChange : function(text){
//If there are errors, run the validator on every change to give quick feedback //If there are errors, run the validator on every change to give quick feedback
let htmlErrors = this.state.htmlErrors; let htmlErrors = this.state.htmlErrors;
if(htmlErrors.length) htmlErrors = Markdown.validate(text); if(htmlErrors.length) htmlErrors = Markdown.validate(text);
this.setState((prevState)=>({ this.setState((prevState)=>({
brew : { ...prevState.brew, text: text }, brew : { ...prevState.brew, text: text },
isPending : true, isPending : true,
htmlErrors : htmlErrors, htmlErrors : htmlErrors,
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
}), ()=>{if(this.state.autoSave) this.trySave();}); }), ()=>{if(this.state.autoSave) this.trySave();});
}, },
@@ -126,7 +149,10 @@ const EditPage = createClass({
}), ()=>{if(this.state.autoSave) this.trySave();}); }), ()=>{if(this.state.autoSave) this.trySave();});
}, },
handleMetaChange : function(metadata){ handleMetaChange : function(metadata, field=undefined){
if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed
fetchThemeBundle(this, metadata.renderer, metadata.theme);
this.setState((prevState)=>({ this.setState((prevState)=>({
brew : { brew : {
...prevState.brew, ...prevState.brew,
@@ -134,13 +160,22 @@ const EditPage = createClass({
}, },
isPending : true, isPending : true,
}), ()=>{if(this.state.autoSave) this.trySave();}); }), ()=>{if(this.state.autoSave) this.trySave();});
}, },
hasChanges : function(){ hasChanges : function(){
return !_.isEqual(this.state.brew, this.savedBrew); 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){ trySave : function(immediate=false){
if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT); if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT);
if(this.hasChanges()){ if(this.hasChanges()){
@@ -193,6 +228,9 @@ const EditPage = createClass({
htmlErrors : Markdown.validate(prevState.brew.text) htmlErrors : Markdown.validate(prevState.brew.text)
})); }));
updateHistory(this.state.brew);
versionHistoryGarbageCollection();
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
const brew = this.state.brew; const brew = this.state.brew;
@@ -378,7 +416,7 @@ const EditPage = createClass({
post to reddit post to reddit
</Nav.item> </Nav.item>
</Nav.dropdown> </Nav.dropdown>
<PrintLink shareId={this.processShareId()} /> <PrintNavItem />
<RecentNavItem brew={this.state.brew} storageKey='edit' /> <RecentNavItem brew={this.state.brew} storageKey='edit' />
<Account /> <Account />
</Nav.section> </Nav.section>
@@ -392,6 +430,7 @@ const EditPage = createClass({
{this.renderNavbar()} {this.renderNavbar()}
<div className='content'> <div className='content'>
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} />}
<SplitPane onDragFinish={this.handleSplitMove}> <SplitPane onDragFinish={this.handleSplitMove}>
<Editor <Editor
ref={this.editor} ref={this.editor}
@@ -401,15 +440,28 @@ const EditPage = createClass({
onMetaChange={this.handleMetaChange} onMetaChange={this.handleMetaChange}
reportError={this.errorReported} reportError={this.errorReported}
renderer={this.state.brew.renderer} 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 <BrewRenderer
text={this.state.brew.text} text={this.state.brew.text}
style={this.state.brew.style} style={this.state.brew.style}
renderer={this.state.brew.renderer} renderer={this.state.brew.renderer}
theme={this.state.brew.theme} theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors} errors={this.state.htmlErrors}
lang={this.state.brew.lang} 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> </SplitPane>
</div> </div>

View File

@@ -0,0 +1,30 @@
require('./lockNotification.less');
const React = require('react');
import Dialog from '../../../../components/dialog.jsx';
function LockNotification(props) {
props = {
shareId : 0,
disableLock : ()=>{},
message : '',
...props
};
const removeLock = ()=>{
alert(`Not yet implemented - ID ${props.shareId}`);
};
return <Dialog className='lockNotification' blocking closeText='CONTINUE TO EDITOR' >
<h1>BREW LOCKED</h1>
<p>This brew been locked by the Administrators. It will not be accessible by any method other than the Editor until the lock is removed.</p>
<hr />
<h3>LOCK REASON</h3>
<p>{props.message || 'Unable to retrieve Lock Message'}</p>
<hr />
<p>Once you have resolved this issue, click REQUEST LOCK REMOVAL to notify the Administrators for review.</p>
<p>Click CONTINUE TO EDITOR to temporarily hide this notification; it will reappear the next time the page is reloaded.</p>
<button onClick={removeLock}>REQUEST LOCK REMOVAL</button>
</Dialog>;
};
module.exports = LockNotification;

View File

@@ -0,0 +1,27 @@
.lockNotification {
z-index : 1;
width : 80%;
padding : 10px;
margin : 5% 10%;
line-height : 1.5em;
color : black;
text-align : center;
background-color : #CCCCCC;
&::backdrop { background-color : #000000AA; }
button {
margin : 10px;
color : white;
background-color : #333333;
&:hover { background-color : #777777; }
}
h1, h3 {
font-family : 'Open Sans', sans-serif;
font-weight : 800;
}
h1 { font-size : 24px; }
h3 { font-size : 18px; }
}

View File

@@ -2,6 +2,9 @@ const dedent = require('dedent-tabs').default;
const loginUrl = 'https://www.naturalcrit.com/login'; const loginUrl = 'https://www.naturalcrit.com/login';
//001-050 : Brew errors
//050-100 : Other pages errors
const errorIndex = (props)=>{ const errorIndex = (props)=>{
return { return {
// Default catch all // Default catch all
@@ -136,11 +139,32 @@ const errorIndex = (props)=>{
**Brew ID:** ${props.brew.brewId}`, **Brew ID:** ${props.brew.brewId}`,
// Theme load error
'09' : dedent`
## No Homebrewery theme document could be found.
The server could not locate the Homebrewery document. It was likely deleted by
its owner.
:
**Requested access:** ${props.brew.accessType}
**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 // Brew locked by Administrators error
'100' : dedent` '51' : dedent`
## This brew has been locked. ## This brew has been locked.
Please contact the Administrators to unlock this document. Only an author may request that this lock is removed.
: :
@@ -152,7 +176,7 @@ const errorIndex = (props)=>{
// ####### Admin pages errors ####### // ####### Admin pages errors #######
'401': dedent` '52': dedent`
## Authorization Required ## Authorization Required
You need to provide correct administrator credentials to access this page. You need to provide correct administrator credentials to access this page.
@@ -162,7 +186,7 @@ const errorIndex = (props)=>{
[reddit mod mail](https://old.reddit.com/message/compose/?to=/r/homebrewery) or [reddit mod mail](https://old.reddit.com/message/compose/?to=/r/homebrewery) or
in the [hb_issues channel of the Discord of Many Things server](https://discord.gg/domt). in the [hb_issues channel of the Discord of Many Things server](https://discord.gg/domt).
`, `,
'403': dedent` '53': dedent`
## Access Denied ## Access Denied
The credentials you entered are not correct, you may try again, attention, there is a limited number of tries before you are blocked. The credentials you entered are not correct, you may try again, attention, there is a limited number of tries before you are blocked.
@@ -177,7 +201,7 @@ const errorIndex = (props)=>{
as so at our subreddit or discord you will find in the home page. as so at our subreddit or discord you will find in the home page.
`, `,
'470' : dedent` '53' : dedent`
## You have runned out of attempts ## You have runned out of attempts
You have failed to provide correct credentials to access the page too many times, and you have run out of attempts. You have failed to provide correct credentials to access the page too many times, and you have run out of attempts.
@@ -195,7 +219,11 @@ const errorIndex = (props)=>{
In any case, your attempts have been logged, and you will not be capable of doing any more attempt for now. In any case, your attempts have been logged, and you will not be capable of doing any more attempt for now.
`, `,
'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,12 +0,0 @@
//TODO: Depricate
module.exports = function(shareId){
return function(event){
event = event || window.event;
if((event.ctrlKey || event.metaKey) && event.keyCode == 80){
const win = window.open(`/homebrew/print/${shareId}?dialog=true`, '_blank');
win.focus();
event.preventDefault();
}
};
};

View File

@@ -1,7 +1,6 @@
require('./homePage.less'); require('./homePage.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames'); const cx = require('classnames');
const request = require('../../utils/request-middleware.js'); const request = require('../../utils/request-middleware.js');
const { Meta } = require('vitreum/headtags'); const { Meta } = require('vitreum/headtags');
@@ -10,10 +9,11 @@ const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx'); const Navbar = require('../../navbar/navbar.jsx');
const NewBrewItem = require('../../navbar/newbrew.navitem.jsx'); const NewBrewItem = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.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 RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const AccountNavItem = require('../../navbar/account.navitem.jsx'); const AccountNavItem = require('../../navbar/account.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx'); const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const { fetchThemeBundle } = require('../../../../shared/helpers.js');
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx'); const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
const Editor = require('../../editor/editor.jsx'); const Editor = require('../../editor/editor.jsx');
@@ -31,15 +31,22 @@ const HomePage = createClass({
}, },
getInitialState : function() { getInitialState : function() {
return { return {
brew : this.props.brew, brew : this.props.brew,
welcomeText : this.props.brew.text, welcomeText : this.props.brew.text,
error : undefined, error : undefined,
currentEditorPage : 0 currentEditorViewPageNum : 1,
currentEditorCursorPageNum : 1,
currentBrewRendererPageNum : 1,
themeBundle : {}
}; };
}, },
editor : React.createRef(null), editor : React.createRef(null),
componentDidMount : function() {
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
},
handleSave : function(){ handleSave : function(){
request.post('/api') request.post('/api')
.send(this.state.brew) .send(this.state.brew)
@@ -55,10 +62,22 @@ const HomePage = createClass({
handleSplitMove : function(){ handleSplitMove : function(){
this.editor.current.update(); 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){ handleTextChange : function(text){
this.setState((prevState)=>({ this.setState((prevState)=>({
brew : { ...prevState.brew, text: text }, brew : { ...prevState.brew, text: text },
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
})); }));
}, },
renderNavbar : function(){ renderNavbar : function(){
@@ -70,6 +89,7 @@ const HomePage = createClass({
} }
<NewBrewItem /> <NewBrewItem />
<HelpNavItem /> <HelpNavItem />
<VaultNavItem />
<RecentNavItem /> <RecentNavItem />
<AccountNavItem /> <AccountNavItem />
</Nav.section> </Nav.section>
@@ -89,12 +109,22 @@ const HomePage = createClass({
onTextChange={this.handleTextChange} onTextChange={this.handleTextChange}
renderer={this.state.brew.renderer} renderer={this.state.brew.renderer}
showEditButtons={false} 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 <BrewRenderer
text={this.state.brew.text} text={this.state.brew.text}
style={this.state.brew.style} style={this.state.brew.style}
renderer={this.state.brew.renderer} 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> </SplitPane>
</div> </div>

View File

@@ -7,6 +7,7 @@ const request = require('../../utils/request-middleware.js');
const Markdown = require('naturalcrit/markdown.js'); const Markdown = require('naturalcrit/markdown.js');
const Nav = require('naturalcrit/nav/nav.jsx'); const Nav = require('naturalcrit/nav/nav.jsx');
const PrintNavItem = require('../../navbar/print.navitem.jsx');
const Navbar = require('../../navbar/navbar.jsx'); const Navbar = require('../../navbar/navbar.jsx');
const AccountNavItem = require('../../navbar/account.navitem.jsx'); const AccountNavItem = require('../../navbar/account.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx'); const ErrorNavItem = require('../../navbar/error-navitem.jsx');
@@ -18,6 +19,7 @@ const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx'); const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js'); const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
const BREWKEY = 'homebrewery-new'; const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style'; const STYLEKEY = 'homebrewery-new-style';
@@ -37,12 +39,15 @@ const NewPage = createClass({
const brew = this.props.brew; const brew = this.props.brew;
return { return {
brew : brew, brew : brew,
isSaving : false, isSaving : false,
saveGoogle : (global.account && global.account.googleId ? true : false), saveGoogle : (global.account && global.account.googleId ? true : false),
error : null, error : null,
htmlErrors : Markdown.validate(brew.text), htmlErrors : Markdown.validate(brew.text),
currentEditorPage : 0 currentEditorViewPageNum : 1,
currentEditorCursorPageNum : 1,
currentBrewRendererPageNum : 1,
themeBundle : {}
}; };
}, },
@@ -75,10 +80,15 @@ const NewPage = createClass({
saveGoogle : (saveStorage == 'GOOGLE-DRIVE' && this.state.saveGoogle) saveGoogle : (saveStorage == 'GOOGLE-DRIVE' && this.state.saveGoogle)
}); });
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
localStorage.setItem(BREWKEY, brew.text); localStorage.setItem(BREWKEY, brew.text);
if(brew.style) if(brew.style)
localStorage.setItem(STYLEKEY, brew.style); localStorage.setItem(STYLEKEY, brew.style);
localStorage.setItem(METAKEY, JSON.stringify({ 'renderer': brew.renderer, 'theme': brew.theme, 'lang': brew.lang })); 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() { componentWillUnmount : function() {
document.removeEventListener('keydown', this.handleControlKeys); document.removeEventListener('keydown', this.handleControlKeys);
@@ -89,7 +99,7 @@ const NewPage = createClass({
const S_KEY = 83; const S_KEY = 83;
const P_KEY = 80; const P_KEY = 80;
if(e.keyCode == S_KEY) this.save(); if(e.keyCode == S_KEY) this.save();
if(e.keyCode == P_KEY) this.print(); if(e.keyCode == P_KEY) printCurrentBrew();
if(e.keyCode == P_KEY || e.keyCode == S_KEY){ if(e.keyCode == P_KEY || e.keyCode == S_KEY){
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@@ -100,15 +110,26 @@ const NewPage = createClass({
this.editor.current.update(); 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){ handleTextChange : function(text){
//If there are errors, run the validator on every change to give quick feedback //If there are errors, run the validator on every change to give quick feedback
let htmlErrors = this.state.htmlErrors; let htmlErrors = this.state.htmlErrors;
if(htmlErrors.length) htmlErrors = Markdown.validate(text); if(htmlErrors.length) htmlErrors = Markdown.validate(text);
this.setState((prevState)=>({ this.setState((prevState)=>({
brew : { ...prevState.brew, text: text }, brew : { ...prevState.brew, text: text },
htmlErrors : htmlErrors, htmlErrors : htmlErrors,
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
})); }));
localStorage.setItem(BREWKEY, text); localStorage.setItem(BREWKEY, text);
}, },
@@ -120,7 +141,10 @@ const NewPage = createClass({
localStorage.setItem(STYLEKEY, style); localStorage.setItem(STYLEKEY, style);
}, },
handleMetaChange : function(metadata){ handleMetaChange : function(metadata, field=undefined){
if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed
fetchThemeBundle(this, metadata.renderer, metadata.theme);
this.setState((prevState)=>({ this.setState((prevState)=>({
brew : { ...prevState.brew, ...metadata }, brew : { ...prevState.brew, ...metadata },
}), ()=>{ }), ()=>{
@@ -140,8 +164,6 @@ const NewPage = createClass({
isSaving : true isSaving : true
}); });
console.log('saving new brew');
let brew = this.state.brew; let brew = this.state.brew;
// Split out CSS to Style if CSS codefence exists // Split out CSS to Style if CSS codefence exists
if(brew.text.startsWith('```css') && brew.text.indexOf('```\n\n') > 0) { if(brew.text.startsWith('```css') && brew.text.indexOf('```\n\n') > 0) {
@@ -151,12 +173,10 @@ const NewPage = createClass({
} }
brew.pageCount=((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1; brew.pageCount=((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
const res = await request const res = await request
.post(`/api${this.state.saveGoogle ? '?saveToGoogle=true' : ''}`) .post(`/api${this.state.saveGoogle ? '?saveToGoogle=true' : ''}`)
.send(brew) .send(brew)
.catch((err)=>{ .catch((err)=>{
console.log(err);
this.setState({ isSaving: false, error: err }); this.setState({ isSaving: false, error: err });
}); });
if(!res) return; if(!res) return;
@@ -180,16 +200,6 @@ const NewPage = createClass({
} }
}, },
print : function(){
window.open('/print?dialog=true&local=print', '_blank');
},
renderLocalPrintButton : function(){
return <Nav.item color='purple' icon='far fa-file-pdf' onClick={this.print}>
get PDF
</Nav.item>;
},
renderNavbar : function(){ renderNavbar : function(){
return <Navbar> return <Navbar>
@@ -202,7 +212,7 @@ const NewPage = createClass({
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> : <ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
this.renderSaveButton() this.renderSaveButton()
} }
{this.renderLocalPrintButton()} <PrintNavItem />
<HelpNavItem /> <HelpNavItem />
<RecentNavItem /> <RecentNavItem />
<AccountNavItem /> <AccountNavItem />
@@ -222,15 +232,27 @@ const NewPage = createClass({
onStyleChange={this.handleStyleChange} onStyleChange={this.handleStyleChange}
onMetaChange={this.handleMetaChange} onMetaChange={this.handleMetaChange}
renderer={this.state.brew.renderer} 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 <BrewRenderer
text={this.state.brew.text} text={this.state.brew.text}
style={this.state.brew.style} style={this.state.brew.style}
renderer={this.state.brew.renderer} renderer={this.state.brew.renderer}
theme={this.state.brew.theme} theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors} errors={this.state.htmlErrors}
lang={this.state.brew.lang} 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> </SplitPane>
</div> </div>

View File

@@ -1,112 +0,0 @@
require('./printPage.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const { Meta } = require('vitreum/headtags');
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
const Markdown = require('naturalcrit/markdown.js');
const Themes = require('themes/themes.json');
const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style';
const METAKEY = 'homebrewery-new-meta';
const PrintPage = createClass({
displayName : 'PrintPage',
getDefaultProps : function() {
return {
query : {},
brew : {
text : '',
style : '',
renderer : 'legacy',
lang : ''
}
};
},
getInitialState : function() {
return {
brew : {
text : this.props.brew.text || '',
style : this.props.brew.style || undefined,
renderer : this.props.brew.renderer || 'legacy',
theme : this.props.brew.theme || '5ePHB',
lang : this.props.brew.lang || 'en'
}
};
},
componentDidMount : function() {
if(this.props.query.local == 'print'){
const brewStorage = localStorage.getItem(BREWKEY);
const styleStorage = localStorage.getItem(STYLEKEY);
const metaStorage = JSON.parse(localStorage.getItem(METAKEY));
this.setState((prevState, prevProps)=>{
return {
brew : {
text : brewStorage,
style : styleStorage,
renderer : metaStorage?.renderer || 'legacy',
theme : metaStorage?.theme || '5ePHB',
lang : metaStorage?.lang || 'en'
}
};
});
}
if(this.props.query.dialog) window.print();
},
renderStyle : function() {
if(!this.state.brew.style) return;
//return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `<style>@layer styleTab {\n${this.state.brew.style}\n} </style>` }} />;
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `<style>\n${this.state.brew.style}\n</style>` }} />;
},
renderPages : function(){
if(this.state.brew.renderer == 'legacy') {
return _.map(this.state.brew.text.split('\\page'), (pageText, index)=>{
return <div
className='phb page'
id={`p${index + 1}`}
dangerouslySetInnerHTML={{ __html: MarkdownLegacy.render(pageText) }}
key={index} />;
});
} else {
return _.map(this.state.brew.text.split(/^\\page$/gm), (pageText, index)=>{
pageText += `\n\n&nbsp;\n\\column\n&nbsp;`; //Artificial column break at page end to emulate column-fill:auto (until `wide` is used, when column-fill:balance will reappear)
return (
<div className='page' id={`p${index + 1}`} key={index} >
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: Markdown.render(pageText) }} />
</div>
);
});
}
},
render : function(){
const rendererPath = this.state.brew.renderer == 'V3' ? 'V3' : 'Legacy';
const themePath = this.state.brew.theme ?? '5ePHB';
const baseThemePath = Themes[rendererPath][themePath].baseTheme;
return <div>
<Meta name='robots' content='noindex, nofollow' />
<link href={`/themes/${rendererPath}/Blank/style.css`} type='text/css' rel='stylesheet'/>
{baseThemePath &&
<link href={`/themes/${rendererPath}/${baseThemePath}/style.css`} type='text/css' rel='stylesheet'/>
}
<link href={`/themes/${rendererPath}/${themePath}/style.css`} type='text/css' rel='stylesheet'/>
{/* Apply CSS from Style tab */}
{this.renderStyle()}
<div className='pages' lang={this.state.brew.lang}>
{this.renderPages()}
</div>
</div>;
}
});
module.exports = PrintPage;

View File

@@ -1,3 +0,0 @@
.printPage{
}

View File

@@ -6,25 +6,33 @@ const { Meta } = require('vitreum/headtags');
const Nav = require('naturalcrit/nav/nav.jsx'); const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx'); const Navbar = require('../../navbar/navbar.jsx');
const MetadataNav = require('../../navbar/metadata.navitem.jsx'); const MetadataNav = require('../../navbar/metadata.navitem.jsx');
const PrintLink = require('../../navbar/print.navitem.jsx'); const PrintNavItem = require('../../navbar/print.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both; const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const Account = require('../../navbar/account.navitem.jsx'); const Account = require('../../navbar/account.navitem.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx'); const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js'); const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
const SharePage = createClass({ const SharePage = createClass({
displayName : 'SharePage', displayName : 'SharePage',
getDefaultProps : function() { getDefaultProps : function() {
return { return {
brew : DEFAULT_BREW_LOAD brew : DEFAULT_BREW_LOAD,
disableMeta : false
};
},
getInitialState : function() {
return {
themeBundle : {}
}; };
}, },
componentDidMount : function() { componentDidMount : function() {
document.addEventListener('keydown', this.handleControlKeys); document.addEventListener('keydown', this.handleControlKeys);
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
}, },
componentWillUnmount : function() { componentWillUnmount : function() {
@@ -35,7 +43,7 @@ const SharePage = createClass({
if(!(e.ctrlKey || e.metaKey)) return; if(!(e.ctrlKey || e.metaKey)) return;
const P_KEY = 80; const P_KEY = 80;
if(e.keyCode == P_KEY){ if(e.keyCode == P_KEY){
window.open(`/print/${this.processShareId()}?dialog=true`, '_blank').focus(); if(e.keyCode == P_KEY) printCurrentBrew();
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
} }
@@ -61,18 +69,26 @@ const SharePage = createClass({
}, },
render : function(){ render : function(){
const titleStyle = this.props.disableMeta ? { cursor: 'default' } : {};
const titleEl = <Nav.item className='brewTitle' style={titleStyle}>{this.props.brew.title}</Nav.item>;
return <div className='sharePage sitePage'> return <div className='sharePage sitePage'>
<Meta name='robots' content='noindex, nofollow' /> <Meta name='robots' content='noindex, nofollow' />
<Navbar> <Navbar>
<Nav.section className='titleSection'> <Nav.section className='titleSection'>
<MetadataNav brew={this.props.brew}> {
<Nav.item className='brewTitle'>{this.props.brew.title}</Nav.item> this.props.disableMeta ?
</MetadataNav> titleEl
:
<MetadataNav brew={this.props.brew}>
{titleEl}
</MetadataNav>
}
</Nav.section> </Nav.section>
<Nav.section> <Nav.section>
{this.props.brew.shareId && <> {this.props.brew.shareId && <>
<PrintLink shareId={this.processShareId()} /> <PrintNavItem/>
<Nav.dropdown> <Nav.dropdown>
<Nav.item color='red' icon='fas fa-code'> <Nav.item color='red' icon='fas fa-code'>
source source
@@ -95,7 +111,14 @@ const SharePage = createClass({
</Navbar> </Navbar>
<div className='content'> <div className='content'>
<BrewRenderer text={this.props.brew.text} style={this.props.brew.style} renderer={this.props.brew.renderer} theme={this.props.brew.theme} /> <BrewRenderer
text={this.props.brew.text}
style={this.props.brew.style}
renderer={this.props.brew.renderer}
theme={this.props.brew.theme}
themeBundle={this.state.themeBundle}
allowPrint={true}
/>
</div> </div>
</div>; </div>;
} }

View File

@@ -12,6 +12,7 @@ const Account = require('../../navbar/account.navitem.jsx');
const NewBrew = require('../../navbar/newbrew.navitem.jsx'); const NewBrew = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx'); const HelpNavItem = require('../../navbar/help.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx'); const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const VaultNavitem = require('../../navbar/vault.navitem.jsx');
const UserPage = createClass({ const UserPage = createClass({
displayName : 'UserPage', displayName : 'UserPage',
@@ -66,6 +67,7 @@ const UserPage = createClass({
} }
<NewBrew /> <NewBrew />
<HelpNavItem /> <HelpNavItem />
<VaultNavitem/>
<RecentNavItem /> <RecentNavItem />
<Account /> <Account />
</Nav.section> </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 { .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 { .position-top-left {
content: url('../icons/position-top-left.svg'); mask-image: url('../icons/position-top-left.svg');
} }
.position-top-right { .position-top-right {
content: url('../icons/position-top-right.svg'); mask-image: url('../icons/position-top-right.svg');
} }
.position-bottom-left { .position-bottom-left {
content: url('../icons/position-bottom-left.svg'); mask-image: url('../icons/position-bottom-left.svg');
} }
.position-bottom-right { .position-bottom-right {
content: url('../icons/position-bottom-right.svg'); mask-image: url('../icons/position-bottom-right.svg');
} }
.position-top { .position-top {
content: url('../icons/position-top.svg'); mask-image: url('../icons/position-top.svg');
} }
.position-right { .position-right {
content: url('../icons/position-right.svg'); mask-image: url('../icons/position-right.svg');
} }
.position-bottom { .position-bottom {
content: url('../icons/position-bottom.svg'); mask-image: url('../icons/position-bottom.svg');
} }
.position-left { .position-left {
content: url('../icons/position-left.svg'); mask-image: url('../icons/position-left.svg');
} }
.mask-edge { .mask-edge {
content: url('../icons/mask-edge.svg'); mask-image: url('../icons/mask-edge.svg');
} }
.mask-corner { .mask-corner {
content: url('../icons/mask-corner.svg'); mask-image: url('../icons/mask-corner.svg');
} }
.mask-center { .mask-center {
content: url('../icons/mask-center.svg'); mask-image: url('../icons/mask-center.svg');
} }
.book-front-cover { .book-front-cover {
content: url('../icons/book-front-cover.svg'); mask-image: url('../icons/book-front-cover.svg');
} }
.book-back-cover { .book-back-cover {
content: url('../icons/book-back-cover.svg'); mask-image: url('../icons/book-back-cover.svg');
} }
.book-inside-cover { .book-inside-cover {
content: url('../icons/book-inside-cover.svg'); mask-image: url('../icons/book-inside-cover.svg');
} }
.book-part-cover { .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 { .davek {
content: url('../icons/Davek.svg'); mask-image: url('../icons/Davek.svg');
} }
.rellanic { .rellanic {
content: url('../icons/Rellanic.svg'); mask-image: url('../icons/Rellanic.svg');
} }
.iokharic { .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

View File

@@ -4,6 +4,7 @@
"secret" : "secret", "secret" : "secret",
"web_port" : 8000, "web_port" : 8000,
"enable_v3" : true, "enable_v3" : true,
"enable_themes" : true,
"local_environments" : ["docker", "local"], "local_environments" : ["docker", "local"],
"publicUrl" : "https://homebrewery.naturalcrit.com" "publicUrl" : "https://homebrewery.naturalcrit.com"
} }

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 }
}]
}
}
];

30452
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "homebrewery", "name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown", "description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.12.0", "version": "3.15.0",
"engines": { "engines": {
"npm": "^10.2.x", "npm": "^10.2.x",
"node": "^20.8.x" "node": "^20.8.x"
@@ -15,14 +15,16 @@
"quick": "node scripts/quick.js", "quick": "node scripts/quick.js",
"build": "node scripts/buildHomebrew.js && node scripts/buildAdmin.js", "build": "node scripts/buildHomebrew.js && node scripts/buildAdmin.js",
"builddev": "node scripts/buildHomebrew.js --dev", "builddev": "node scripts/buildHomebrew.js --dev",
"lint": "eslint --fix **/*.{js,jsx}", "lint": "eslint --fix",
"lint:dry": "eslint **/*.{js,jsx}", "lint:dry": "eslint",
"stylelint": "stylelint --fix **/*.{less}", "stylelint": "stylelint --fix **/*.{less}",
"stylelint:dry": "stylelint **/*.less", "stylelint:dry": "stylelint **/*.less",
"circleci": "npm test && eslint **/*.{js,jsx} --max-warnings=0", "circleci": "npm test && eslint **/*.{js,jsx} --max-warnings=0",
"verify": "npm run lint && npm test", "verify": "npm run lint && npm test",
"test": "jest --runInBand", "test": "jest --runInBand",
"test:api-unit": "jest server/*.spec.js --verbose", "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:coverage": "jest --coverage --silent --runInBand",
"test:dev": "jest --verbose --watch", "test:dev": "jest --verbose --watch",
"test:basic": "jest tests/markdown/basic.test.js --verbose", "test:basic": "jest tests/markdown/basic.test.js --verbose",
@@ -32,6 +34,7 @@
"test:mustache-syntax:block": "jest \".*(mustache-syntax).*\" -t '^Block:.*' --verbose --noStackTrace", "test:mustache-syntax:block": "jest \".*(mustache-syntax).*\" -t '^Block:.*' --verbose --noStackTrace",
"test:mustache-syntax:injection": "jest \".*(mustache-syntax).*\" -t '^Injection:.*' --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: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:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
"test:route": "jest tests/routes/static-pages.test.js --verbose", "test:route": "jest tests/routes/static-pages.test.js --verbose",
"phb": "node scripts/phb.js", "phb": "node scripts/phb.js",
@@ -56,15 +59,15 @@
], ],
"coverageThreshold": { "coverageThreshold": {
"global": { "global": {
"statements": 25, "statements": 50,
"branches": 10, "branches": 40,
"functions": 22, "functions": 40,
"lines": 25 "lines": 50
}, },
"server/homebrew.api.js": { "server/homebrew.api.js": {
"statements": 65, "statements": 70,
"branches": 50, "branches": 50,
"functions": 60, "functions": 65,
"lines": 70 "lines": 70
} }
}, },
@@ -82,57 +85,58 @@
] ]
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.24.5", "@babel/core": "^7.25.2",
"@babel/plugin-transform-runtime": "^7.24.3", "@babel/plugin-transform-runtime": "^7.25.4",
"@babel/preset-env": "^7.24.5", "@babel/preset-env": "^7.25.4",
"@babel/preset-react": "^7.24.1", "@babel/preset-react": "^7.24.7",
"@googleapis/drive": "^8.8.0", "@googleapis/drive": "^8.14.0",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"codemirror": "^5.65.6", "codemirror": "^5.65.6",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"create-react-class": "^15.7.0", "create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3", "dedent-tabs": "^0.10.3",
"dompurify": "^3.1.4", "dompurify": "^3.1.6",
"expr-eval": "^2.0.2", "expr-eval": "^2.0.2",
"express": "^4.19.2", "express": "^4.21.0",
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
"express-rate-limit": "^7.2.0", "express-rate-limit": "^7.2.0",
"express-static-gzip": "2.1.7", "express-static-gzip": "2.1.8",
"fs-extra": "11.2.0", "fs-extra": "11.2.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jwt-simple": "^0.5.6", "jwt-simple": "^0.5.6",
"less": "^3.13.1", "less": "^3.13.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "11.2.0", "marked": "11.2.0",
"marked-emoji": "^1.4.0", "marked-emoji": "^1.4.2",
"marked-extended-tables": "^1.0.8", "marked-extended-tables": "^1.0.10",
"marked-gfm-heading-id": "^3.1.3", "marked-gfm-heading-id": "^3.2.0",
"marked-smartypants-lite": "^1.0.2", "marked-smartypants-lite": "^1.0.2",
"markedLegacy": "npm:marked@^0.3.19", "markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongoose": "^8.4.0", "mongoose": "^8.6.2",
"nanoid": "3.3.4", "nanoid": "3.3.4",
"nconf": "^0.12.1", "nconf": "^0.12.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-frame-component": "^4.1.3", "react-frame-component": "^4.1.3",
"react-router-dom": "6.23.1", "react-router-dom": "6.26.2",
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"superagent": "^9.0.2", "superagent": "^10.1.0",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git" "vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.57.0", "@stylistic/stylelint-plugin": "^3.0.1",
"eslint-plugin-jest": "^28.5.0", "eslint": "^9.10.0",
"eslint-plugin-react": "^7.34.1", "eslint-plugin-jest": "^28.8.3",
"eslint-plugin-react": "^7.36.1",
"globals": "^15.9.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",
"stylelint": "^15.11.0", "stylelint": "^16.9.0",
"stylelint-config-recess-order": "^4.6.0", "stylelint-config-recess-order": "^5.1.0",
"stylelint-config-recommended": "^13.0.0", "stylelint-config-recommended": "^14.0.1",
"stylelint-stylistic": "^0.4.3",
"supertest": "^7.0.0" "supertest": "^7.0.0"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,16 @@ const Markdown = require('../shared/naturalcrit/markdown.js');
const yaml = require('js-yaml'); const yaml = require('js-yaml');
const asyncHandler = require('express-async-handler'); const asyncHandler = require('express-async-handler');
const { nanoid } = require('nanoid'); const { nanoid } = require('nanoid');
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
const { DEFAULT_BREW, DEFAULT_BREW_LOAD } = require('./brewDefaults.js'); const { DEFAULT_BREW, DEFAULT_BREW_LOAD } = require('./brewDefaults.js');
const Themes = require('../themes/themes.json');
const isStaticTheme = (renderer, themeName)=>{
return Themes[renderer]?.[themeName] !== undefined;
};
// const getTopBrews = (cb) => { // const getTopBrews = (cb) => {
// HomebrewModel.find().sort({ views: -1 }).limit(5).exec(function(err, brews) { // HomebrewModel.find().sort({ views: -1 }).limit(5).exec(function(err, brews) {
// cb(brews); // cb(brews);
@@ -37,6 +44,43 @@ const api = {
} }
return { id, googleId }; return { id, googleId };
}, },
//Get array of any of this user's brews tagged with `meta:theme`
getUsersBrewThemes : async (username)=>{
if(!username)
return {};
const fields = [
'title',
'tags',
'shareId',
'thumbnail',
'textBin',
'text',
'authors',
'renderer'
];
const userThemes = {};
const brews = await HomebrewModel.getByUser(username, true, fields, { tags: { $in: ['meta:theme', 'meta:Theme'] } });
if(brews) {
for (const brew of brews) {
userThemes[brew.renderer] ??= {};
userThemes[brew.renderer][brew.shareId] = {
name : brew.title,
renderer : brew.renderer,
baseTheme : brew.theme,
baseSnippets : false,
author : brew.authors[0],
path : brew.shareId,
thumbnail : brew.thumbnail || '/assets/naturalCritLogoWhite.svg'
};
}
}
return userThemes;
},
getBrew : (accessType, stubOnly = false)=>{ getBrew : (accessType, stubOnly = false)=>{
// Create middleware with the accessType passed in as part of the scope // Create middleware with the accessType passed in as part of the scope
return async (req, res, next)=>{ return async (req, res, next)=>{
@@ -55,7 +99,7 @@ const api = {
stub = stub?.toObject(); stub = stub?.toObject();
if(stub?.lock?.locked && accessType != 'edit') { if(stub?.lock?.locked && accessType != 'edit') {
throw { HBErrorCode: '100', code: stub.lock.code, message: stub.lock.message, 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 // If there is a google id, try to find the google brew
@@ -104,6 +148,20 @@ const api = {
next(); 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)=>{ mergeBrewText : (brew)=>{
let text = brew.text; let text = brew.text;
if(brew.style !== undefined) { if(brew.style !== undefined) {
@@ -142,7 +200,7 @@ const api = {
return modified; return modified;
}, },
excludeStubProps : (brew)=>{ excludeStubProps : (brew)=>{
const propsToExclude = ['text', 'textBin', 'renderer', 'pageCount']; const propsToExclude = ['text', 'textBin'];
for (const prop of propsToExclude) { for (const prop of propsToExclude) {
brew[prop] = undefined; brew[prop] = undefined;
} }
@@ -209,6 +267,58 @@ const api = {
res.status(200).send(saved); res.status(200).send(saved);
}, },
getThemeBundle : async(req, res)=>{
/* getThemeBundle: Collects the theme and all parent themes
returns an object containing an array of css, and an array of snippets, in render order
req.params.id : The shareId ( User theme ) or name ( static theme )
req.params.renderer : The Markdown renderer used for this theme */
req.params.renderer = _.upperFirst(req.params.renderer);
let currentTheme;
const completeStyles = [];
const completeSnippets = [];
while (req.params.id) {
//=== User Themes ===//
if(!isStaticTheme(req.params.renderer, req.params.id)) {
await api.getBrew('share')(req, res, ()=>{})
.catch((err)=>{
if(err.HBErrorCode == '05')
err = { ...err, name: 'ThemeLoad Error', message: 'Theme Not Found', HBErrorCode: '09' };
throw err;
});
currentTheme = req.brew;
splitTextStyleAndMetadata(currentTheme);
// If there is anything in the snippets or style members, append them to the appropriate array
if(currentTheme?.snippets) completeSnippets.push(JSON.parse(currentTheme.snippets));
if(currentTheme?.style) completeStyles.push(`/* From Brew: ${req.protocol}://${req.get('host')}/share/${req.params.id} */\n\n${currentTheme.style}`);
req.params.id = currentTheme.theme;
req.params.renderer = currentTheme.renderer;
}
//=== Static Themes ===//
else {
const localSnippets = `${req.params.renderer}_${req.params.id}`; // Just log the name for loading on client
const localStyle = `@import url(\"/themes/${req.params.renderer}/${req.params.id}/style.css\");`;
completeSnippets.push(localSnippets);
completeStyles.push(`/* From Theme ${req.params.id} */\n\n${localStyle}`);
req.params.id = Themes[req.params.renderer][req.params.id].baseTheme;
}
}
const returnObj = {
// Reverse the order of the arrays so they are listed oldest parent to youngest child.
styles : completeStyles.reverse(),
snippets : completeSnippets.reverse()
};
res.setHeader('Content-Type', 'application/json');
return res.status(200).send(returnObj);
},
updateBrew : async (req, res)=>{ updateBrew : async (req, res)=>{
// Initialize brew from request and body, destructure query params, and set the initial value for the after-save method // Initialize brew from request and body, destructure query params, and set the initial value for the after-save method
const brewFromClient = api.excludePropsFromUpdate(req.body); const brewFromClient = api.excludePropsFromUpdate(req.body);
@@ -369,5 +479,6 @@ router.put('/api/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api
router.put('/api/update/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew)); router.put('/api/update/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.delete('/api/:id', asyncHandler(api.deleteBrew)); router.delete('/api/:id', asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', asyncHandler(api.deleteBrew)); router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
module.exports = api; module.exports = api;

View File

@@ -14,6 +14,9 @@ describe('Tests for api', ()=>{
let saved; let saved;
beforeEach(()=>{ beforeEach(()=>{
jest.resetModules();
jest.restoreAllMocks();
saved = undefined; saved = undefined;
saveFunc = jest.fn(async function() { saveFunc = jest.fn(async function() {
saved = { ...this, _id: '1' }; saved = { ...this, _id: '1' };
@@ -45,8 +48,10 @@ describe('Tests for api', ()=>{
model.mockImplementation((brew)=>modelBrew(brew)); model.mockImplementation((brew)=>modelBrew(brew));
res = { res = {
status : jest.fn(()=>res), status : jest.fn(()=>res),
send : jest.fn(()=>{}) send : jest.fn(()=>{}),
set : jest.fn(()=>{}),
setHeader : jest.fn(()=>{})
}; };
api = require('./homebrew.api'); api = require('./homebrew.api');
@@ -81,10 +86,6 @@ describe('Tests for api', ()=>{
}; };
}); });
afterEach(()=>{
jest.restoreAllMocks();
});
describe('getId', ()=>{ describe('getId', ()=>{
it('should return only id if google id is not present', ()=>{ it('should return only id if google id is not present', ()=>{
const { id, googleId } = api.getId({ const { id, googleId } = api.getId({
@@ -300,7 +301,7 @@ describe('Tests for api', ()=>{
}); });
it('access is denied to a locked brew', async()=>{ it('access is denied to a locked brew', async()=>{
const lockBrew = { title: 'test brew', shareId: '1', lock: { locked: true, code: 404, message: 'brew locked' } }; const lockBrew = { title: 'test brew', shareId: '1', lock: { locked: true, code: 404, shareMessage: 'brew locked' } };
model.get = jest.fn(()=>toBrewPromise(lockBrew)); model.get = jest.fn(()=>toBrewPromise(lockBrew));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined })); api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
@@ -308,7 +309,7 @@ describe('Tests for api', ()=>{
const req = { brew: {} }; const req = { brew: {} };
const next = jest.fn(); 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' });
}); });
}); });
@@ -408,8 +409,8 @@ brew`);
expect(sent).not.toEqual(googleBrew); expect(sent).not.toEqual(googleBrew);
expect(result.text).toBeUndefined(); expect(result.text).toBeUndefined();
expect(result.textBin).toBeUndefined(); expect(result.textBin).toBeUndefined();
expect(result.renderer).toBeUndefined(); expect(result.renderer).toBe('v3');
expect(result.pageCount).toBeUndefined(); expect(result.pageCount).toBe(1);
}); });
}); });
@@ -540,9 +541,9 @@ brew`);
description : '', description : '',
editId : expect.any(String), editId : expect.any(String),
gDrive : false, gDrive : false,
pageCount : undefined, pageCount : 1,
published : false, published : false,
renderer : undefined, renderer : 'V3',
lang : 'en', lang : 'en',
shareId : expect.any(String), shareId : expect.any(String),
googleId : expect.any(String), googleId : expect.any(String),
@@ -581,6 +582,121 @@ brew`);
}); });
}); });
describe('Theme bundle', ()=>{
it('should return Theme Bundle for a User Theme', async ()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: null, shareId: 'userThemeAID', style: 'User Theme A Style' }
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
await api.getThemeBundle(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
styles : ['/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'],
snippets : []
});
});
it('should return Theme Bundle for nested User Themes', async ()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style' },
userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style' },
userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: null, shareId: 'userThemeCID', style: 'User Theme C Style' }
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
await api.getThemeBundle(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
styles : [
'/* From Brew: https://localhost/share/userThemeCID */\n\nUser Theme C Style',
'/* From Brew: https://localhost/share/userThemeBID */\n\nUser Theme B Style',
'/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'
],
snippets : []
});
});
it('should return Theme Bundle for a Static Theme', async ()=>{
const req = { params: { renderer: 'V3', id: '5ePHB' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
await api.getThemeBundle(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
styles : [
`/* From Theme Blank */\n\n@import url("/themes/V3/Blank/style.css");`,
`/* From Theme 5ePHB */\n\n@import url("/themes/V3/5ePHB/style.css");`
],
snippets : [
'V3_Blank',
'V3_5ePHB'
]
});
});
it('should return Theme Bundle for nested User and Static Themes together', async ()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style' },
userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style' },
userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: '5eDMG', shareId: 'userThemeCID', style: 'User Theme C Style' }
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
await api.getThemeBundle(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
styles : [
`/* From Theme Blank */\n\n@import url("/themes/V3/Blank/style.css");`,
`/* From Theme 5ePHB */\n\n@import url("/themes/V3/5ePHB/style.css");`,
`/* From Theme 5eDMG */\n\n@import url("/themes/V3/5eDMG/style.css");`,
'/* From Brew: https://localhost/share/userThemeCID */\n\nUser Theme C Style',
'/* From Brew: https://localhost/share/userThemeBID */\n\nUser Theme B Style',
'/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'
],
snippets : [
'V3_Blank',
'V3_5ePHB',
'V3_5eDMG'
]
});
});
it('should fail for an invalid Theme in the chain', async()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'missingTheme', shareId: 'userThemeAID', style: 'User Theme A Style' },
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
let err;
await api.getThemeBundle(req, res)
.catch((e)=>err = e);
expect(err).toEqual({
HBErrorCode : '09',
accessType : 'share',
brewId : 'missingTheme',
message : 'Theme Not Found',
name : 'ThemeLoad Error',
status : 404 });
});
});
describe('deleteBrew', ()=>{ describe('deleteBrew', ()=>{
it('should handle case where fetching the brew returns an error', async ()=>{ it('should handle case where fetching the brew returns an error', async ()=>{
api.getBrew = jest.fn(()=>async ()=>{ throw { message: 'err', HBErrorCode: '02' }; }); api.getBrew = jest.fn(()=>async ()=>{ throw { message: 'err', HBErrorCode: '02' }; });
@@ -801,4 +917,66 @@ brew`);
expect(saved.googleId).toEqual(brew.googleId); 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('');
});
});
}); });

View File

@@ -50,8 +50,8 @@ HomebrewSchema.statics.get = async function(query, fields=null){
return brew; return brew;
}; };
HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, fields=null){ HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, fields=null, filter=null){
const query = { authors: username, published: true }; const query = { authors: username, published: true, ...filter };
if(allowAccess){ if(allowAccess){
delete query.published; delete query.published;
} }

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

@@ -1,5 +1,6 @@
const _ = require('lodash'); const _ = require('lodash');
const yaml = require('js-yaml'); const yaml = require('js-yaml');
const request = require('../client/homebrew/utils/request-middleware.js');
const splitTextStyleAndMetadata = (brew)=>{ const splitTextStyleAndMetadata = (brew)=>{
brew.text = brew.text.replaceAll('\r\n', '\n'); brew.text = brew.text.replaceAll('\r\n', '\n');
@@ -15,8 +16,43 @@ const splitTextStyleAndMetadata = (brew)=>{
brew.style = brew.text.slice(7, index - 1); brew.style = brew.text.slice(7, index - 1);
brew.text = brew.text.slice(index + 5); brew.text = brew.text.slice(index + 5);
} }
if(brew.text.startsWith('```snippets')) {
const index = brew.text.indexOf('```\n\n');
brew.snippets = brew.text.slice(11, index - 1);
brew.text = brew.text.slice(index + 5);
}
};
const printCurrentBrew = ()=>{
if(window.typeof !== 'undefined') {
window.frames['BrewRenderer'].contentWindow.print();
//Force DOM reflow; Print dialog causes a repaint, and @media print CSS somehow makes out-of-view pages disappear
const node = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer').item(0);
node.style.display='none';
node.offsetHeight; // accessing this is enough to trigger a reflow
node.style.display='';
}
};
const fetchThemeBundle = async (obj, renderer, theme)=>{
if(!renderer || !theme) return;
const res = await request
.get(`/api/theme/${renderer}/${theme}`)
.catch((err)=>{
obj.setState({ error: err });
});
if(!res) return;
const themeBundle = res.body;
themeBundle.joinedStyles = themeBundle.styles.map((style)=>`<style>${style}</style>`).join('\n\n');
obj.setState((prevState)=>({
...prevState,
themeBundle : themeBundle
}));
}; };
module.exports = { module.exports = {
splitTextStyleAndMetadata splitTextStyleAndMetadata,
printCurrentBrew,
fetchThemeBundle,
}; };

View File

@@ -3,7 +3,7 @@ const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const _ = require('lodash'); const _ = require('lodash');
const DISMISS_KEY = 'dismiss_render_warning'; import Dialog from '../../../client/components/dialog.jsx';
const RenderWarnings = createClass({ const RenderWarnings = createClass({
displayName : 'RenderWarnings', displayName : 'RenderWarnings',
@@ -34,9 +34,6 @@ const RenderWarnings = createClass({
}, },
}, },
checkWarnings : function(){ checkWarnings : function(){
const hideDismiss = localStorage.getItem(DISMISS_KEY);
if(hideDismiss) return this.setState({ warnings: {} });
this.setState({ this.setState({
warnings : _.reduce(this.warnings, (r, fn, type)=>{ warnings : _.reduce(this.warnings, (r, fn, type)=>{
const element = fn(); const element = fn();
@@ -45,20 +42,18 @@ const RenderWarnings = createClass({
}, {}) }, {})
}); });
}, },
dismiss : function(){
localStorage.setItem(DISMISS_KEY, true);
this.checkWarnings();
},
render : function(){ render : function(){
if(_.isEmpty(this.state.warnings)) return null; if(_.isEmpty(this.state.warnings)) return null;
return <div className='renderWarnings'> const DISMISS_KEY = 'dismiss_render_warning';
<i className='fas fa-times dismiss' onClick={this.dismiss}/> const DISMISS_TEXT = <i className='fas fa-times dismiss' />;
return <Dialog className='renderWarnings' dismissKey={DISMISS_KEY} closeText={DISMISS_TEXT}>
<i className='fas fa-exclamation-triangle ohno' /> <i className='fas fa-exclamation-triangle ohno' />
<h3>Render Warnings</h3> <h3>Render Warnings</h3>
<small>If this homebrew is rendering badly if might be because of the following:</small> <small>If this homebrew is rendering badly if might be because of the following:</small>
<ul>{_.values(this.state.warnings)}</ul> <ul>{_.values(this.state.warnings)}</ul>
</div>; </Dialog>;
} }
}); });

View File

@@ -1,53 +1,48 @@
.renderWarnings{ .renderWarnings {
position : relative; position : relative;
float : right; float : right;
display : inline-block;
width : 350px; width : 350px;
padding : 20px; padding : 20px;
padding-bottom : 10px; padding-bottom : 10px;
padding-left : 85px; padding-left : 85px;
margin-bottom : 10px; margin-bottom : 10px;
background-color : @yellow;
color : white; color : white;
a{ background-color : @yellow;
font-weight : 800; border : none;
} a { font-weight : 800; }
i.ohno{ i.ohno {
position : absolute; position : absolute;
top : 24px; top : 24px;
left : 24px; left : 24px;
opacity : 0.8;
font-size : 2.5em; font-size : 2.5em;
opacity : 0.8;
} }
i.dismiss{ button.dismiss {
position : absolute; position : absolute;
top : 10px; top : 10px;
right : 10px; right : 10px;
cursor : pointer; cursor : pointer;
opacity : 0.6; background-color : transparent;
&:hover{ opacity : 0.6;
opacity : 1; &:hover { opacity : 1; }
}
} }
small{ small {
opacity : 0.7;
font-size : 0.6em; font-size : 0.6em;
opacity : 0.7;
} }
h3{ h3 {
font-size : 1.1em; font-size : 1.1em;
font-weight : 800; font-weight : 800;
} }
ul{ ul {
margin-top : 15px; margin-top : 15px;
font-size : 0.8em; font-size : 0.8em;
list-style-position : outside; list-style-position : outside;
list-style-type : disc; list-style-type : disc;
li{ li {
font-size : 0.8em; font-size : 0.8em;
line-height : 1.6em; line-height : 1.6em;
em{ em { font-weight : 800; }
font-weight : 800;
}
} }
} }
} }

View File

@@ -39,8 +39,10 @@ if(typeof window !== 'undefined'){
//Autocompletion //Autocompletion
require('codemirror/addon/hint/show-hint.js'); require('codemirror/addon/hint/show-hint.js');
const foldCode = require('./fold-code'); const foldPagesCode = require('./fold-pages');
foldCode.registerHomebreweryHelper(CodeMirror); foldPagesCode.registerHomebreweryHelper(CodeMirror);
const foldCSSCode = require('./fold-css');
foldCSSCode.registerHomebreweryHelper(CodeMirror);
} }
const CodeEditor = createClass({ const CodeEditor = createClass({
@@ -395,6 +397,11 @@ const CodeEditor = createClass({
getCursorPosition : function(){ getCursorPosition : function(){
return this.codeMirror.getCursor(); return this.codeMirror.getCursor();
}, },
getTopVisibleLine : function(){
const rect = this.codeMirror.getWrapperElement().getBoundingClientRect();
const topVisibleLine = this.codeMirror.lineAtHeight(rect.top, 'window');
return topVisibleLine;
},
updateSize : function(){ updateSize : function(){
this.codeMirror.refresh(); this.codeMirror.refresh();
}, },
@@ -411,11 +418,11 @@ const CodeEditor = createClass({
foldOptions : function(cm){ foldOptions : function(cm){
return { return {
scanUp : true, scanUp : true,
rangeFinder : CodeMirror.fold.homebrewery, rangeFinder : this.props.language === 'css' ? CodeMirror.fold.homebrewerycss : CodeMirror.fold.homebrewery,
widget : (from, to)=>{ widget : (from, to)=>{
let text = ''; let text = '';
let currentLine = from.line; let currentLine = from.line;
const maxLength = 50; let maxLength = 50;
let foldPreviewText = ''; let foldPreviewText = '';
while (currentLine <= to.line && text.length <= maxLength) { while (currentLine <= to.line && text.length <= maxLength) {
@@ -430,10 +437,15 @@ const CodeEditor = createClass({
} }
} }
text = foldPreviewText || `Lines ${from.line+1}-${to.line+1}`; text = foldPreviewText || `Lines ${from.line+1}-${to.line+1}`;
text = text.replace('{', '').trim();
// Truncate data URLs at `data:`
const startOfData = text.indexOf('data:');
if(startOfData > 0)
maxLength = Math.min(startOfData + 5, maxLength);
text = text.trim();
if(text.length > maxLength) if(text.length > maxLength)
text = `${text.substr(0, maxLength)}...`; text = `${text.slice(0, maxLength)}...`;
return `\u21A4 ${text} \u21A6`; return `\u21A4 ${text} \u21A6`;
} }
@@ -450,3 +462,4 @@ const CodeEditor = createClass({
}); });
module.exports = CodeEditor; module.exports = CodeEditor;

View File

@@ -8,6 +8,7 @@
@import (less) './themes/fonts/iconFonts/diceFont.less'; @import (less) './themes/fonts/iconFonts/diceFont.less';
@import (less) './themes/fonts/iconFonts/elderberryInn.less'; @import (less) './themes/fonts/iconFonts/elderberryInn.less';
@import (less) './themes/fonts/iconFonts/gameIcons.less'; @import (less) './themes/fonts/iconFonts/gameIcons.less';
@import (less) './themes/fonts/iconFonts/fontAwesome.less';
@keyframes sourceMoveAnimation { @keyframes sourceMoveAnimation {
50% {background-color: red; color: white;} 50% {background-color: red; color: white;}

View File

@@ -0,0 +1,44 @@
module.exports = {
registerHomebreweryHelper : function(CodeMirror) {
CodeMirror.registerHelper('fold', 'homebrewerycss', function(cm, start) {
// BRACE FOLDING
const startMatcher = /\{[ \t]*$/;
const endMatcher = /\}[ \t]*$/;
const activeLine = cm.getLine(start.line);
if(activeLine.match(startMatcher)) {
const lastLineNo = cm.lastLine();
let end = start.line + 1;
let braceCount = 1;
while (end < lastLineNo) {
const curLine = cm.getLine(end);
if(curLine.match(startMatcher)) braceCount++;
if(curLine.match(endMatcher)) braceCount--;
if(braceCount == 0) break;
++end;
}
return {
from : CodeMirror.Pos(start.line, 0),
to : CodeMirror.Pos(end, cm.getLine(end).length)
};
}
// @import and data-url folding
const importMatcher = /^@import.*?;/;
const dataURLMatcher = /url\(.*?data\:.*\)/;
if(activeLine.match(importMatcher) || activeLine.match(dataURLMatcher)) {
return {
from : CodeMirror.Pos(start.line, 0),
to : CodeMirror.Pos(start.line, activeLine.length)
};
}
return null;
});
}
};

View File

@@ -3,7 +3,7 @@ const _ = require('lodash');
const Marked = require('marked'); const Marked = require('marked');
const MarkedExtendedTables = require('marked-extended-tables'); const MarkedExtendedTables = require('marked-extended-tables');
const { markedSmartypantsLite: MarkedSmartypantsLite } = require('marked-smartypants-lite'); 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'); const { markedEmoji: MarkedEmojis } = require('marked-emoji');
//Icon fonts included so they can appear in emoji autosuggest dropdown //Icon fonts included so they can appear in emoji autosuggest dropdown
@@ -28,17 +28,18 @@ const mathParser = new MathParser({
round : true, round : true,
floor : true, floor : true,
ceil : true, ceil : true,
abs : true,
sin : false, cos : false, tan : false, asin : false, acos : false, sin : false, cos : false, tan : false, asin : false, acos : false,
atan : false, sinh : false, cosh : false, tanh : false, asinh : false, atan : false, sinh : false, cosh : false, tanh : false, asinh : false,
acosh : false, atanh : false, sqrt : false, cbrt : false, log : false, acosh : false, atanh : false, sqrt : false, cbrt : false, log : false,
log2 : false, ln : false, lg : false, log10 : false, expm1 : false, log2 : false, ln : false, lg : false, log10 : false, expm1 : false,
log1p : false, abs : false, trunc : false, join : false, sum : false, log1p : false, trunc : false, join : false, sum : false, indexOf : false,
'-' : false, '+' : false, exp : false, not : false, length : false, '-' : false, '+' : false, exp : false, not : false, length : false,
'!' : false, sign : false, random : false, fac : false, min : false, '!' : false, sign : false, random : false, fac : false, min : false,
max : false, hypot : false, pyt : false, pow : false, atan2 : false, max : false, hypot : false, pyt : false, pow : false, atan2 : false,
'if' : false, gamma : false, roundTo : false, map : false, fold : false, 'if' : false, gamma : false, roundTo : false, map : false, fold : false,
filter : false, indexOf : false, filter : false,
remainder : false, factorial : false, remainder : false, factorial : false,
comparison : false, concatenate : false, comparison : false, concatenate : false,
@@ -46,6 +47,16 @@ const mathParser = new MathParser({
array : false, fndef : false array : false, fndef : false
} }
}); });
// Add sign function
mathParser.functions.sign = function (a) {
if(a >= 0) return '+';
return '-';
};
// Add signed function
mathParser.functions.signed = function (a) {
if(a >= 0) return `+${a}`;
return `${a}`;
};
//Processes the markdown within an HTML block if it's just a class-wrapper //Processes the markdown within an HTML block if it's just a class-wrapper
renderer.html = function (html) { renderer.html = function (html) {
@@ -75,7 +86,7 @@ renderer.link = function (href, title, text) {
if(href[0] == '#') { if(href[0] == '#') {
self = true; self = true;
} }
href = cleanUrl(this.options.sanitize, this.options.baseUrl, href); href = cleanUrl(href);
if(href === null) { if(href === null) {
return text; return text;
@@ -91,6 +102,20 @@ renderer.link = function (href, title, text) {
return out; 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 // Disable default reflink behavior, as it steps on our variables extension
tokenizer.def = function () { tokenizer.def = function () {
return undefined; return undefined;
@@ -102,7 +127,7 @@ const mustacheSpans = {
start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const completeSpan = /^{{[^\n]*}}/; // Regex for the complete token const completeSpan = /^{{[^\n]*}}/; // Regex for the complete token
const inlineRegex = /{{(?=((?:[:=](?:"['\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *|}}/g; const inlineRegex = /{{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *|}}/g;
const match = completeSpan.exec(src); const match = completeSpan.exec(src);
if(match) { if(match) {
//Find closing delimiter //Find closing delimiter
@@ -159,7 +184,7 @@ const mustacheDivs = {
start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const completeBlock = /^ *{{[^\n}]* *\n.*\n *}}/s; // Regex for the complete token const completeBlock = /^ *{{[^\n}]* *\n.*\n *}}/s; // Regex for the complete token
const blockRegex = /^ *{{(?=((?:[:=](?:"['\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *$|^ *}}$/gm; const blockRegex = /^ *{{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *$|^ *}}$/gm;
const match = completeBlock.exec(src); const match = completeBlock.exec(src);
if(match) { if(match) {
//Find closing delimiter //Find closing delimiter
@@ -214,7 +239,7 @@ const mustacheInjectInline = {
level : 'inline', level : 'inline',
start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/g; const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/g;
const match = inlineRegex.exec(src); const match = inlineRegex.exec(src);
if(match) { if(match) {
const lastToken = tokens[tokens.length - 1]; const lastToken = tokens[tokens.length - 1];
@@ -265,7 +290,7 @@ const mustacheInjectBlock = {
level : 'block', level : 'block',
start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/ym; const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/ym;
const match = inlineRegex.exec(src); const match = inlineRegex.exec(src);
if(match) { if(match) {
const lastToken = tokens[tokens.length - 1]; const lastToken = tokens[tokens.length - 1];
@@ -345,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 = { const definitionListsSingleLine = {
name : 'definitionListsSingleLine', name : 'definitionListsSingleLine',
level : 'block', level : 'block',
@@ -389,9 +435,9 @@ const definitionListsSingleLine = {
const definitionListsMultiLine = { const definitionListsMultiLine = {
name : 'definitionListsMultiLine', name : 'definitionListsMultiLine',
level : 'block', 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) { 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 match;
let endIndex = 0; let endIndex = 0;
const definitions = []; const definitions = [];
@@ -441,7 +487,7 @@ const replaceVar = function(input, hoist=false, allowUnresolved=false) {
const label = match[2]; const label = match[2];
//v=====--------------------< HANDLE MATH >-------------------=====v// //v=====--------------------< HANDLE MATH >-------------------=====v//
const mathRegex = /[a-z]+\(|[+\-*/^()]/g; const mathRegex = /[a-z]+\(|[+\-*/^(),]/g;
const matches = label.split(mathRegex); const matches = label.split(mathRegex);
const mathVars = matches.filter((match)=>isNaN(match))?.map((s)=>s.trim()); // Capture any variable names const mathVars = matches.filter((match)=>isNaN(match))?.map((s)=>s.trim()); // Capture any variable names
@@ -451,7 +497,7 @@ const replaceVar = function(input, hoist=false, allowUnresolved=false) {
mathVars?.forEach((variable)=>{ mathVars?.forEach((variable)=>{
const foundVar = lookupVar(variable, globalPageNumber, hoist); const foundVar = lookupVar(variable, globalPageNumber, hoist);
if(foundVar && foundVar.resolved && foundVar.content && !isNaN(foundVar.content)) // Only subsitute math values if fully resolved, not empty strings, and numbers if(foundVar && foundVar.resolved && foundVar.content && !isNaN(foundVar.content)) // Only subsitute math values if fully resolved, not empty strings, and numbers
replacedLabel = replacedLabel.replaceAll(variable, foundVar.content); replacedLabel = replacedLabel.replaceAll(new RegExp(`(?<!\\w)(${variable})(?!\\w)`, 'g'), foundVar.content);
}); });
try { try {
@@ -695,34 +741,27 @@ const MarkedEmojiOptions = {
renderer : (token)=>`<i class="${token.emoji}"></i>` renderer : (token)=>`<i class="${token.emoji}"></i>`
}; };
const tableTerminators = [
`:+\\n`, // hardBreak
` *{[^\n]+}`, // blockInjector
` *{{[^{\n]*\n.*?\n}}` // mustacheDiv
];
Marked.use(MarkedVariables()); 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(mustacheInjectBlock);
Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false }); 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; function cleanUrl(href) {
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;
}
}
try { try {
href = encodeURI(href).replace(/%25/g, '%'); href = encodeURI(href).replace(/%25/g, '%');
} catch (e) { } catch {
return null; return null;
} }
return href; return href;
}; }
const escapeTest = /[&<>"']/; const escapeTest = /[&<>"']/;
const escapeReplace = /[&<>"']/g; const escapeReplace = /[&<>"']/g;
@@ -771,7 +810,8 @@ const processStyleTags = (string)=>{
const attributes = _.remove(tags, (tag)=>(tag.includes('='))).map((tag)=>tag.replace(/="?([^"]*)"?/g, '="$1"')) const attributes = _.remove(tags, (tag)=>(tag.includes('='))).map((tag)=>tag.replace(/="?([^"]*)"?/g, '="$1"'))
?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="')) ?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="'))
.reduce((obj, attr)=>{ .reduce((obj, attr)=>{
let [key, value] = attr.split('='); const index = attr.indexOf('=');
let [key, value] = [attr.substring(0, index), attr.substring(index + 1)];
value = value.replace(/"/g, ''); value = value.replace(/"/g, '');
obj[key] = value; obj[key] = value;
return obj; return obj;
@@ -786,14 +826,17 @@ const processStyleTags = (string)=>{
}; };
}; };
//Given a string representing an HTML element, extract all of its properties (id, class, style, and other attributes)
const extractHTMLStyleTags = (htmlString)=>{ const extractHTMLStyleTags = (htmlString)=>{
const id = htmlString.match(/id="([^"]*)"/)?.[1] || null; const firstElementOnly = htmlString.split('>')[0];
const classes = htmlString.match(/class="([^"]*)"/)?.[1] || null; const id = firstElementOnly.match(/id="([^"]*)"/)?.[1] || null;
const styles = htmlString.match(/style="([^"]*)"/)?.[1] || null; const classes = firstElementOnly.match(/class="([^"]*)"/)?.[1] || null;
const attributes = htmlString.match(/[a-zA-Z]+="[^"]*"/g) const styles = firstElementOnly.match(/style="([^"]*)"/)?.[1] || null;
const attributes = firstElementOnly.match(/[a-zA-Z]+="[^"]*"/g)
?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="')) ?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="'))
.reduce((obj, attr)=>{ .reduce((obj, attr)=>{
let [key, value] = attr.split('='); const index = attr.indexOf('=');
let [key, value] = [attr.substring(0, index), attr.substring(index + 1)];
value = value.replace(/"/g, ''); value = value.replace(/"/g, '');
obj[key] = value; obj[key] = value;
return obj; return obj;
@@ -813,13 +856,15 @@ let globalPageNumber = 0;
module.exports = { module.exports = {
marked : Marked, marked : Marked,
render : (rawBrewText, pageNumber=1)=>{ render : (rawBrewText, pageNumber=0)=>{
globalVarsList[pageNumber] = {}; //Reset global links for current page, to ensure values are parsed in order globalVarsList[pageNumber] = {}; //Reset global links for current page, to ensure values are parsed in order
varsQueue = []; //Could move into MarkedVariables() varsQueue = []; //Could move into MarkedVariables()
globalPageNumber = pageNumber; globalPageNumber = pageNumber;
if(pageNumber==0) {
MarkedGFMResetHeadingIDs();
}
rawBrewText = rawBrewText.replace(/^\\column$/gm, `\n<div class='columnSplit'></div>\n`) rawBrewText = rawBrewText.replace(/^\\column$/gm, `\n<div class='columnSplit'></div>\n`);
.replace(/^(:+)$/gm, (match)=>`${`<div class='blank'></div>`.repeat(match.length)}\n`);
const opts = Marked.defaults; const opts = Marked.defaults;
rawBrewText = opts.hooks.preprocess(rawBrewText); rawBrewText = opts.hooks.preprocess(rawBrewText);

View File

@@ -47,8 +47,8 @@ const Nav = {
color : null color : null
}; };
}, },
handleClick : function(){ handleClick : function(e){
this.props.onClick(); this.props.onClick(e);
}, },
render : function(){ render : function(){
const classes = cx('navItem', this.props.color, this.props.className); const classes = cx('navItem', this.props.color, this.props.className);

View File

@@ -7,8 +7,9 @@ const SplitPane = createClass({
displayName : 'SplitPane', displayName : 'SplitPane',
getDefaultProps : function() { getDefaultProps : function() {
return { return {
storageKey : 'naturalcrit-pane-split', storageKey : 'naturalcrit-pane-split',
onDragFinish : function(){} //fires when dragging onDragFinish : function(){}, //fires when dragging
showDividerButtons : true
}; };
}, },
@@ -41,6 +42,10 @@ const SplitPane = createClass({
}); });
} }
window.addEventListener('resize', this.handleWindowResize); 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() { componentWillUnmount : function() {
@@ -88,6 +93,11 @@ const SplitPane = createClass({
userSetDividerPos : newSize userSetDividerPos : newSize
}); });
}, },
liveScrollToggle : function() {
window.localStorage.setItem('liveScroll', String(!this.state.liveScroll));
this.setState({ liveScroll: !this.state.liveScroll });
},
/* /*
unFocus : function() { unFocus : function() {
if(document.selection){ if(document.selection){
@@ -119,13 +129,18 @@ const SplitPane = createClass({
onClick={()=>this.setState({ moveBrew: !this.state.moveBrew })} > onClick={()=>this.setState({ moveBrew: !this.state.moveBrew })} >
<i className='fas fa-arrow-right' /> <i className='fas fa-arrow-right' />
</div> </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(){ renderDivider : function(){
return <> return <>
{this.renderMoveArrows()} {this.props.showDividerButtons && this.renderMoveArrows()}
<div className='divider' onPointerDown={this.handleDown} > <div className='divider' onPointerDown={this.handleDown} >
<div className='dots'> <div className='dots'>
<i className='fas fa-circle' /> <i className='fas fa-circle' />
@@ -142,9 +157,12 @@ const SplitPane = createClass({
width={this.state.currentDividerPos} width={this.state.currentDividerPos}
> >
{React.cloneElement(this.props.children[0], { {React.cloneElement(this.props.children[0], {
moveBrew : this.state.moveBrew, ...(this.props.showDividerButtons && {
moveSource : this.state.moveSource, moveBrew : this.state.moveBrew,
setMoveArrows : this.setMoveArrows moveSource : this.state.moveSource,
liveScroll : this.state.liveScroll,
setMoveArrows : this.setMoveArrows,
}),
})} })}
</Pane> </Pane>
{this.renderDivider()} {this.renderDivider()}

View File

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

View File

@@ -1,5 +1,5 @@
const stylelint = require('stylelint'); const stylelint = require('stylelint');
const { isNumber } = require('stylelint/lib/utils/validateTypes'); const { isNumber } = require('stylelint/lib/utils/validateTypes.cjs');
const { report, ruleMessages, validateOptions } = stylelint.utils; const { report, ruleMessages, validateOptions } = stylelint.utils;
const ruleName = 'naturalcrit/declaration-block-multi-line-min-declarations'; const ruleName = 'naturalcrit/declaration-block-multi-line-min-declarations';

View File

@@ -1,5 +1,5 @@
const stylelint = require('stylelint'); const stylelint = require('stylelint');
const { isNumber } = require('stylelint/lib/utils/validateTypes'); const { isNumber } = require('stylelint/lib/utils/validateTypes.cjs');
const { report, ruleMessages, validateOptions } = stylelint.utils; const { report, ruleMessages, validateOptions } = stylelint.utils;
const ruleName = 'naturalcrit/declaration-colon-min-space-before'; const ruleName = 'naturalcrit/declaration-colon-min-space-before';

View File

@@ -88,4 +88,16 @@ describe('Multiline Definition Lists', ()=>{
const rendered = Markdown.render(source).trim(); 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>'); 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() { 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(); 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() { it('Renders an element modified by only the first of two consecutive injections', function() {
@@ -333,10 +333,29 @@ describe('Injection: When an injection tag follows an element', ()=>{
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><span class="inline-block" style="color:red;">text</span>{background:blue}</p>'); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><span class="inline-block" style="color:red;">text</span>{background:blue}</p>');
}); });
it('Renders an parent and child element, each modified by an injector', function() {
const source = dedent`**bolded text**{color:red}
{color:blue}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p style="color:blue;"><strong style="color:red;">bolded text</strong></p>');
});
it('Renders an image with added attributes', function() { 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 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(); 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="--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="--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 source = `![alt text](url)`;
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent` 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() { it('Renders normal images with a title', function() {
const source = 'An image ![alt text](url "and title")!'; const source = 'An image ![alt text](url "and title")!';
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent` 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() { it('Applies curly injectors to images', function() {
const source = `![alt text](url){width:100px}`; const source = `![alt text](url){width:100px}`;
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent` 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() { it('Renders normal links', function() {
@@ -370,4 +370,36 @@ describe('Cross-page variables', ()=>{
const rendered = renderAllPages([source0, source1]).join('\n\\page\n').trimReturns(); const rendered = renderAllPages([source0, source1]).join('\n\\page\n').trimReturns();
expect(rendered, `Input:\n${[source0, source1].join('\n\\page\n')}`, { showPrefix: false }).toBe('<p>two</p><p>one</p>\\page<p>two</p>'); expect(rendered, `Input:\n${[source0, source1].join('\n\\page\n')}`, { showPrefix: false }).toBe('<p>two</p><p>one</p>\\page<p>two</p>');
}); });
});
describe('Math function parameter handling', ()=>{
it('allows variables in single-parameter functions', function() {
const source = '[var]:4.1\n\n$[floor(var)]';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>4</p>`);
});
it('allows one variable and a number in two-parameter functions', function() {
const source = '[var]:4\n\n$[min(1,var)]';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>1</p>`);
});
it('allows two variables in two-parameter functions', function() {
const source = '[var1]:4\n\n[var2]:8\n\n$[min(var1,var2)]';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>4</p>`);
});
});
describe('Variable names that are subsets of other names', ()=>{
it('do not conflict with function names', function() {
const source = `[a]: -1\n\n$[abs(a)]`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered).toBe('<p>1</p>');
});
it('do not conflict with other variable names', function() {
const source = `[ab]: 2\n\n[aba]: 8\n\n[ba]: 4\n\n$[ab + aba + ba]`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered).toBe('<p>14</p>');
});
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name" : "5e PHB", "name" : "5e PHB",
"renderer" : "V3", "renderer" : "V3",
"baseTheme" : false, "baseTheme" : "Blank",
"baseSnippets" : false "baseSnippets" : false
} }

View File

@@ -21,9 +21,162 @@ module.exports = [
view : 'text', view : 'text',
snippets : [ snippets : [
{ {
name : 'Table of Contents', name : 'Table of Contents',
icon : 'fas fa-book', icon : 'fas fa-book',
gen : TableOfContentsGen gen : TableOfContentsGen,
experimental : true,
subsnippets : [
{
name : 'Generate Table of Contents',
icon : 'fas fa-book',
gen : TableOfContentsGen,
experimental : true
},
{
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 : '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 : '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`,
},
]
}
]
}, },
{ {
name : 'Index', name : 'Index',
@@ -60,7 +213,7 @@ module.exports = [
background-image: linear-gradient(-45deg, #322814, #998250, #322814); background-image: linear-gradient(-45deg, #322814, #998250, #322814);
line-height: 1em; line-height: 1em;
}\n\n` }\n\n`
} },
] ]
}, },
@@ -315,7 +468,7 @@ module.exports = [
/* Ink Friendly */ /* Ink Friendly */
*:is(.page,.monster,.note,.descriptive) { *:is(.page,.monster,.note,.descriptive) {
background : white !important; background : white !important;
filter : drop-shadow(0px 0px 3px #888) !important; box-shadow : 1px 4px 14px #888 !important;
} }
.page img { .page img {

View File

@@ -1,86 +1,78 @@
const _ = require('lodash');
const dedent = require('dedent-tabs').default; const dedent = require('dedent-tabs').default;
const getTOC = (pages)=>{ // Map each actual page to its footer label, accounting for skips or numbering resets
const add1 = (title, page)=>{ const mapPages = (pages)=>{
res.push({ let actualPage = 0;
title : title, let mappedPage = 0; // Number displayed in footer
page : page + 1, const pageMap = [];
children : []
});
};
const add2 = (title, page)=>{
if(!_.last(res)) add1(null, page);
_.last(res).children.push({
title : title,
page : page + 1,
children : []
});
};
const add3 = (title, page)=>{
if(!_.last(res)) add1(null, page);
if(!_.last(_.last(res).children)) add2(null, page);
_.last(_.last(res).children).children.push({
title : title,
page : page + 1,
children : []
});
};
const res = []; pages.forEach((page)=>{
_.each(pages, (page, pageNum)=>{ actualPage++;
if(!page.includes('{{frontCover}}') && !page.includes('{{insideCover}}') && !page.includes('{{partCover}}') && !page.includes('{{backCover}}')) { const doSkip = page.querySelector('.skipCounting');
const lines = page.split('\n'); const doReset = page.querySelector('.resetCounting');
_.each(lines, (line)=>{
if(_.startsWith(line, '# ')){ if(doReset)
const title = line.replace('# ', ''); mappedPage = 1;
add1(title, pageNum); if(!doSkip && !doReset)
} mappedPage++;
if(_.startsWith(line, '## ')){
const title = line.replace('## ', ''); pageMap[actualPage] = {
add2(title, pageNum); mappedPage : mappedPage,
} showPage : !doSkip
if(_.startsWith(line, '### ')){ };
const title = line.replace('### ', '');
add3(title, pageNum);
}
});
}
}); });
return res; 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();
}
depthChain.push(depth);
}
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 pages = iframeDocument.querySelectorAll('.page');
const pageMap = mapPages(pages);
return getMarkdown(headings, pageMap);
}; };
module.exports = function(props){ module.exports = function(props){
const pages = props.brew.text.split('\\page'); const TOC = getTOC();
const TOC = getTOC(pages);
const markdown = _.reduce(TOC, (r, g1, idx1)=>{
if(g1.title !== null) {
r.push(`- ### [{{ ${g1.title}}}{{ ${g1.page}}}](#p${g1.page})`);
}
if(g1.children.length){
_.each(g1.children, (g2, idx2)=>{
if(g2.title !== null) {
r.push(` - #### [{{ ${g2.title}}}{{ ${g2.page}}}](#p${g2.page})`);
}
if(g2.children.length){
_.each(g2.children, (g3, idx3)=>{
if(g2.title !== null) {
r.push(` - [{{ ${g3.title}}}{{ ${g3.page}}}](#p${g3.page})`);
} else { // Don't over-indent if no level-2 parent entry
r.push(` - [{{ ${g3.title}}}{{ ${g3.page}}}](#p${g3.page})`);
}
});
}
});
}
return r;
}, []).join('\n');
return dedent` return dedent`
{{toc,wide {{toc,wide
# Contents # Contents
${markdown} ${TOC}
}} }}
\n`; \n`;
}; };

View File

@@ -11,6 +11,7 @@
--HB_Color_CaptionText : #766649; // Brown --HB_Color_CaptionText : #766649; // Brown
--HB_Color_WatercolorStain : #BBAD82; // Light brown --HB_Color_WatercolorStain : #BBAD82; // Light brown
--HB_Color_Footnotes : #C9AD6A; // Gold --HB_Color_Footnotes : #C9AD6A; // Gold
--TOC : 'include';
} }
.useSansSerif() { .useSansSerif() {
@@ -382,6 +383,14 @@
.useColumns(0.96, @fillMode: balance); .useColumns(0.96, @fillMode: balance);
} }
//only for IOS devices
@supports (-webkit-touch-callout: none) {
.page .monster.frame {
background-repeat : no-repeat;
background-size : cover;
}
}
// ***************************** // *****************************
// * FOOTER // * FOOTER
// *****************************/ // *****************************/
@@ -459,6 +468,7 @@
margin-left : 1.5em; margin-left : 1.5em;
} }
} }
// ***************************** // *****************************
// * SPELL LIST // * SPELL LIST
// *****************************/ // *****************************/
@@ -786,6 +796,65 @@
// ***************************** // *****************************
// * TABLE OF CONTENTS // * TABLE OF CONTENTS
// *****************************/ // *****************************/
// Default Exclusions
// Anything not excluded is included, default Headers are H1, H2, and H3.
h4,
h5,
h6,
.page:has(.frontCover),
.page:has(.backCover),
.page:has(.insideCover),
.monster,
.noToC,
.toc { --TOC: exclude; }
// 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; }
.tocIncludeH4 h4 {--TOC: include; }
.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 {
--TOC: include;
}
}
.page { .page {
&:has(.toc)::after { display : none; } &:has(.toc)::after { display : none; }
.toc { .toc {
@@ -850,6 +919,9 @@
.useColumns(0.96, @fillMode: balance); .useColumns(0.96, @fillMode: balance);
} }
} }
.toc.wide li {
break-inside: auto;
}
} }
// ***************************** // *****************************
@@ -874,6 +946,10 @@
.page h1 + * { margin-top : 0; } .page h1 + * { margin-top : 0; }
.page .descriptive.wide + * {
margin-top: 0;
}
//***************************** //*****************************
// * RUNE TABLE // * RUNE TABLE
// *****************************/ // *****************************/

View File

@@ -23,14 +23,30 @@ module.exports = [
gen : '\n\\page\n' gen : '\n\\page\n'
}, },
{ {
name : 'Page Number', name : 'Page Numbering',
icon : 'fas fa-bookmark', icon : 'fas fa-bookmark',
gen : '{{pageNumber 1}}\n' subsnippets : [
}, {
{ name : 'Page Number',
name : 'Auto-incrementing Page Number', icon : 'fas fa-bookmark',
icon : 'fas fa-sort-numeric-down', gen : '{{pageNumber 1}}\n'
gen : '{{pageNumber,auto}}\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', name : 'Footer',
@@ -153,6 +169,18 @@ module.exports = [
gen : dedent` gen : dedent`
![cat warrior](https://s-media-cache-ak0.pinimg.com/736x/4a/81/79/4a8179462cfdf39054a418efd4cb743e.jpg) {width:325px,mix-blend-mode:multiply}` ![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', name : 'Background Image',
icon : 'fas fa-tree', icon : 'fas fa-tree',

View File

@@ -3,6 +3,7 @@
@import (less) './themes/fonts/iconFonts/elderberryInn.less'; @import (less) './themes/fonts/iconFonts/elderberryInn.less';
@import (less) './themes/fonts/iconFonts/diceFont.less'; @import (less) './themes/fonts/iconFonts/diceFont.less';
@import (less) './themes/fonts/iconFonts/gameIcons.less'; @import (less) './themes/fonts/iconFonts/gameIcons.less';
@import (less) './themes/fonts/iconFonts/fontAwesome.less';
:root { :root {
//Colors //Colors
@@ -11,7 +12,7 @@
} }
@page { margin : 0; } @page { margin : 0; }
body { counter-reset : page-numbers; } body { counter-reset : page-numbers 0; }
* { -webkit-print-color-adjust : exact; } * { -webkit-print-color-adjust : exact; }
//***************************** //*****************************
@@ -50,7 +51,6 @@ body { counter-reset : page-numbers; }
height : 279.4mm; height : 279.4mm;
padding : 1.4cm 1.9cm 1.7cm; padding : 1.4cm 1.9cm 1.7cm;
overflow : hidden; overflow : hidden;
counter-increment : page-numbers;
background-color : var(--HB_Color_Background); background-color : var(--HB_Color_Background);
text-rendering : optimizeLegibility; text-rendering : optimizeLegibility;
contain : size; contain : size;
@@ -155,6 +155,19 @@ body { counter-reset : page-numbers; }
break-inside : avoid; 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 */
.watermark { .watermark {
position : absolute; position : absolute;
@@ -235,7 +248,7 @@ body { counter-reset : page-numbers; }
left : 50%; left : 50%;
width : 50%; width : 50%;
height : 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 { & img {
position : absolute; position : absolute;
@@ -480,4 +493,13 @@ body { counter-reset : page-numbers; }
&:nth-child(even) { &:nth-child(even) {
.pageNumber { left : 30px; } .pageNumber { left : 30px; }
} }
}
.resetCounting {
counter-set : page-numbers 1;
}
&:not(:has(.skipCounting)) {
counter-increment : page-numbers;
}
}

View File

@@ -1,6 +1,6 @@
{ {
"name" : "Journal", "name" : "Journal",
"renderer" : "V3", "renderer" : "V3",
"baseTheme" : false, "baseTheme" : "Blank",
"baseSnippets" : "5ePHB" "baseSnippets" : "5ePHB"
} }

View File

@@ -7,6 +7,7 @@
@noteBorderImage : url('/assets/noteBorder.png'); @noteBorderImage : url('/assets/noteBorder.png');
@descriptiveBoxImage : url('/assets/descriptiveBorder.png'); @descriptiveBoxImage : url('/assets/descriptiveBorder.png');
@monsterBlockBackground : url('/assets/parchmentBackgroundGrayscale.jpg'); @monsterBlockBackground : url('/assets/parchmentBackgroundGrayscale.jpg');
@monsterBlockOverlay : url('/assets/parchmentBackgroundOverlayed.jpg');
@monsterBorderImage : url('/assets/monsterBorderFancy.png'); @monsterBorderImage : url('/assets/monsterBorderFancy.png');
@codeBorderImage : url('/assets/codeBorder.png'); @codeBorderImage : url('/assets/codeBorder.png');
@classTableDecoration : url('/assets/classTableDecoration.png'); @classTableDecoration : url('/assets/classTableDecoration.png');

View File

@@ -58,7 +58,7 @@
background-color: rgba(35,153,153,0.5); background-color: rgba(35,153,153,0.5);
} }
.pageLine { .pageLine {
background-color: rgba(255,255,255,0.75); background-color: rgba(255,255,255,0.5);
& ~ pre.CodeMirror-line { & ~ pre.CodeMirror-line {
color: black; color: black;
} }
@@ -85,4 +85,4 @@
// Future styling for themes with light backgrounds // Future styling for themes with light backgrounds
--dummyVar: 'currently unused'; --dummyVar: 'currently unused';
} }
} }

View File

@@ -74,8 +74,9 @@
@font-face { @font-face {
font-family: SolberaImitationRemake; //Tweaked 5e version font-family: SolberaImitationRemake; //Tweaked 5e version
src: url('../../../fonts/5e/Solbera Imitation Tweak.woff2'); src: url('../../../fonts/5e/Solbera Imitation Tweak.woff2');
font-weight: normal; font-weight: 100 1000;
font-style: normal; font-style: normal;
font-style: italic;
} }
/* Cover Page */ /* Cover Page */

View File

@@ -7,7 +7,7 @@
} }
.df { .df {
display : inline-block; display : inline;
font-family : 'DiceFont'; font-family : 'DiceFont';
font-style : normal; font-style : normal;
font-weight : normal; font-weight : normal;
@@ -16,8 +16,11 @@
text-decoration : inherit; text-decoration : inherit;
text-transform : none; text-transform : none;
text-rendering : optimizeLegibility; text-rendering : optimizeLegibility;
-moz-osx-font-smoothing : grayscale;
/* Better Font Rendering =========== */
-webkit-font-smoothing : antialiased; -webkit-font-smoothing : antialiased;
-moz-osx-font-smoothing : grayscale;
&.F::before { content : '\f190'; } &.F::before { content : '\f190'; }
&.F-minus::before { content : '\f191'; } &.F-minus::before { content : '\f191'; }
&.F-plus::before { content : '\f192'; } &.F-plus::before { content : '\f192'; }

View File

@@ -7,15 +7,16 @@
} }
.ei { .ei {
display : inline-block; display : inline;
margin-right : 3px;
font-family : 'Elderberry-Inn'; font-family : 'Elderberry-Inn';
line-height : 1; line-height : 1;
vertical-align : baseline; vertical-align : baseline;
-moz-osx-font-smoothing : grayscale;
-webkit-font-smoothing : antialiased;
text-rendering : auto; text-rendering : auto;
/* Better Font Rendering =========== */
-webkit-font-smoothing : antialiased;
-moz-osx-font-smoothing : grayscale;
&.book::before { content : '\E900'; } &.book::before { content : '\E900'; }
&.screen::before { content : '\E901'; } &.screen::before { content : '\E901'; }

View File

@@ -0,0 +1,2 @@
/* Icon Font: Font Awesome */
.far,.fas,.fab { display : inline; }

View File

@@ -8,19 +8,15 @@
.gi { .gi {
/* use !important to prevent issues with browser extensions that change fonts */ /* use !important to prevent issues with browser extensions that change fonts */
display : inline-block; display : inline;
margin-right : 3px;
font-family : 'Game-Icons' !important; font-family : 'Game-Icons' !important;
line-height : 1; line-height : 1;
vertical-align : baseline; vertical-align : baseline;
-moz-osx-font-smoothing : grayscale;
-webkit-font-smoothing : antialiased;
text-rendering : auto; text-rendering : auto;
/* Better Font Rendering =========== */ /* Better Font Rendering =========== */
-webkit-font-smoothing : antialiased; -webkit-font-smoothing : antialiased;
-moz-osx-font-smoothing : grayscale; -moz-osx-font-smoothing : grayscale;
&.zigzag-leaf::before { content : '\e900'; } &.zigzag-leaf::before { content : '\e900'; }
&.zebra-shield::before { content : '\e901'; } &.zebra-shield::before { content : '\e901'; }

View File

@@ -18,7 +18,7 @@
"5ePHB": { "5ePHB": {
"name": "5e PHB", "name": "5e PHB",
"renderer": "V3", "renderer": "V3",
"baseTheme": false, "baseTheme": "Blank",
"baseSnippets": false, "baseSnippets": false,
"path": "5ePHB" "path": "5ePHB"
}, },
@@ -32,7 +32,7 @@
"Journal": { "Journal": {
"name": "Journal", "name": "Journal",
"renderer": "V3", "renderer": "V3",
"baseTheme": false, "baseTheme": "Blank",
"baseSnippets": "5ePHB", "baseSnippets": "5ePHB",
"path": "Journal" "path": "Journal"
} }