0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-24 14:13:02 +00:00

Compare commits

..

29 Commits

Author SHA1 Message Date
Charlie Humphreys
e8b427ea21 fix a few issues 2023-07-18 22:13:40 -05:00
Charlie Humphreys
379f260de5 add color-selector component and colorings 2023-07-17 00:46:55 -05:00
Charlie Humphreys
46f413c656 update pill margin for snippet fields 2023-07-16 12:59:30 -05:00
Charlie Humphreys
77b0e93dd3 add more image snippets and other snippets 2023-07-16 12:35:08 -05:00
Charlie Humphreys
1b2d8d46a6 delete field component 2023-07-16 02:16:16 -05:00
Charlie Humphreys
62aae96012 Merge branch 'editor-widgets' of github.com:naturalcrit/homebrewery into editor-widgets 2023-07-16 02:15:17 -05:00
Charlie Humphreys
bef6b94dc4 add image selector field type, modal component, and new snippet widgets 2023-07-16 02:14:43 -05:00
Trevor Buckner
c113b8dd1f Merge branch 'editor-widgets' of https://github.com/naturalcrit/homebrewery into editor-widgets 2023-07-16 01:53:24 -04:00
Charlie Humphreys
e6055bd417 add support for function values 2023-07-15 01:05:41 -05:00
Charlie Humphreys
4c087e9aa5 refactor CodeMirror library instantiation 2023-07-15 01:05:41 -05:00
Charlie Humphreys
9d6a9c4ebf update component/key names 2023-07-15 01:05:41 -05:00
Charlie Humphreys
18c94f95f3 adjust field/checkbox styles 2023-07-15 01:05:41 -05:00
Charlie Humphreys
23f2f1f53b update based on feedback 2023-07-15 01:05:41 -05:00
Trevor Buckner
d044229b49 clean up codeEditor 2023-07-15 01:05:41 -05:00
Trevor Buckner
8e40cec051 tweak cClass logic 2023-07-15 01:05:41 -05:00
Trevor Buckner
ebbf0ca88b Simplify click-outside close widget logic 2023-07-15 01:05:41 -05:00
Charlie Humphreys
51760e02e7 fix ref issues and remove unneeded value 2023-07-15 01:05:41 -05:00
Charlie Humphreys
f52d42bef5 update widgets - add hints component and adjust autocomplete logic 2023-07-15 01:05:41 -05:00
Charlie Humphreys
3af5d27e3e add the concept of widgets and widget fields 2023-07-15 01:05:41 -05:00
Charlie
314275122d Merge pull request #2907 from naturalcrit/CleanCodeEditor.jsx
[Widgets] clean up codeEditor
2023-07-04 12:03:52 -05:00
Charlie
5716e4fcfd Merge pull request #2908 from naturalcrit/tweak-cClass-logic
[Widgets] Tweak cClass logic
2023-07-04 12:02:11 -05:00
Trevor Buckner
b9aaee43c2 tweak cClass logic 2023-07-04 03:31:21 -04:00
Trevor Buckner
35a74b3e46 clean up codeEditor 2023-07-03 17:16:29 -04:00
Charlie
f4fe08f8fd Merge pull request #2906 from naturalcrit/SimplifyMouseclickToggle 2023-07-03 15:58:47 -05:00
Trevor Buckner
65c0c81984 Merge branch 'master' into editor-widgets 2023-07-03 16:35:41 -04:00
Trevor Buckner
712f0309e9 Simplify click-outside close widget logic 2023-07-03 15:27:18 -04:00
Charlie Humphreys
b7be2d6463 fix ref issues and remove unneeded value 2023-06-30 00:37:20 -05:00
Charlie Humphreys
47c84d9f01 update widgets - add hints component and adjust autocomplete logic 2023-06-30 00:18:23 -05:00
Charlie Humphreys
b6d37dd825 add the concept of widgets and widget fields 2023-06-10 13:02:13 -05:00
130 changed files with 8942 additions and 12498 deletions

View File

@@ -5,12 +5,12 @@
version: 2.1
orbs:
node: circleci/node@5.1.0
node: circleci/node@3.0.0
jobs:
build:
docker:
- image: cimg/node:20.8.0
- image: cimg/node:16.11.0
- image: mongo:4.4
working_directory: ~/homebrewery
@@ -27,7 +27,7 @@ jobs:
# fallback to using the latest cache if no exact match is found
- v1-dependencies-
- run: sudo npm install -g npm@10.2.0
- run: sudo npm install -g npm@8.10.0
- node/install-packages:
app-dir: ~/homebrewery
cache-path: node_modules
@@ -45,7 +45,7 @@ jobs:
test:
docker:
- image: cimg/node:20.8.0
- image: cimg/node:16.11.0
working_directory: ~/homebrewery
parallelism: 1
@@ -64,12 +64,6 @@ jobs:
- run:
name: Test - Mustache Spans
command: npm run test:mustache-syntax
- run:
name: Test - Definition Lists
command: npm run test:definition-lists
- run:
name: Test - Variables
command: npm run test:variables
- run:
name: Test - Routes
command: npm run test:route

View File

@@ -1,103 +0,0 @@
name: Limit pull requests
description: >
Limit the number of open pull requests to the repository created by a user
author: ZhongRuoyu (from Homebrew repository)
branding:
icon: alert-triangle
color: yellow
inputs:
token:
description: GitHub token
required: false
default: ${{ github.token }}
except-users:
description: The users exempted from the limit, one per line
required: false
# https://docs.github.com/en/graphql/reference/enums#commentauthorassociation
except-author-associations:
description: The author associations exempted from the limit, one per line
required: false
comment-limit:
description: >
Post the comment when the user's number of open pull requests exceeds this
number and `comment` is not empty
required: true
default: "10"
comment:
description: The comment to post when the limit is reached
required: false
close-limit:
description: >
Close the pull request when the user's number of open pull requests
exceeds this number and `close` is set to `true`
required: true
default: "50"
close:
description: Whether to close the pull request when the limit is reached
required: true
default: "false"
runs:
using: composite
steps:
- name: Check the number of pull requests
id: count-pull-requests
run: |
# If the user is exempted, assume they have no pull requests.
if grep -Fiqx '${{ github.actor }}' <<<"$EXCEPT_USERS"; then
echo "::notice::@${{ github.actor }} is exempted from the limit."
echo "count=0" >>"$GITHUB_OUTPUT"
exit 0
fi
if grep -Fiqx '${{ github.event.pull_request.author_association }}' <<<"$EXCEPT_AUTHOR_ASSOCIATIONS"; then
echo "::notice::@{{ github.actor }} is a ${{ github.event.pull_request.author_association }} exempted from the limit."
echo "count=0" >>"$GITHUB_OUTPUT"
exit 0
fi
count="$(
gh api \
--method GET \
--header 'Accept: application/vnd.github+json' \
--header 'X-GitHub-Api-Version: 2022-11-28' \
--field state=open \
--paginate \
'/repos/{owner}/{repo}/pulls' |
jq \
--raw-output \
--arg USER '${{ github.actor }}' \
'map(select(.user.login == $USER)) | length'
)"
echo "::notice::@${{ github.actor }} has $count open pull request(s)."
echo "count=$count" >>"$GITHUB_OUTPUT"
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ inputs.token }}
EXCEPT_USERS: ${{ inputs.except-users }}
EXCEPT_AUTHOR_ASSOCIATIONS: ${{ inputs.except-author-associations }}
shell: bash
- name: Comment on pull request
if: >
fromJSON(steps.count-pull-requests.outputs.count) > fromJSON(inputs.comment-limit) &&
inputs.comment != ''
run: |
gh pr comment '${{ github.event.pull_request.number }}' \
--body="${COMMENT_BODY}"
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ inputs.token }}
COMMENT_BODY: ${{ inputs.comment }}
shell: bash
- name: Close pull request
if: >
fromJSON(steps.count-pull-requests.outputs.count) > fromJSON(inputs.close-limit) &&
inputs.close == 'true'
run: |
gh pr close '${{ github.event.pull_request.number }}'
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ inputs.token }}
shell: bash

View File

@@ -1,29 +0,0 @@
name: PR Check
on: pull_request_target
env:
GH_REPO: ${{ github.repository }}
GH_NO_UPDATE_NOTIFIER: 1
GH_PROMPT_DISABLED: 1
permissions:
contents: read
issues: write
pull-requests: write
statuses: write
jobs:
limit-pull-requests:
if: always() && github.repository_owner == 'naturalcrit'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name : Run limit-pull-requests action
uses: ./.github/actions/limit-pull-requests
with:
except-users: |
dependabot
comment-limit: 3
comment: |
Hi, thanks for your contribution to the Homebrewery! You already have >=3 open pull requests. Consider completing some of your existing PRs before opening new ones. Thanks!
close-limit: 5
close: false

View File

@@ -75,426 +75,11 @@ pre {
.page {
padding-bottom: 1.5cm;
}
.varSyntaxTable th:first-of-type {
width:6cm;
}
```
## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### 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
{{taskList
##### 5e-Cleric
* [x] Fix language-specific hyphenation on print page
Fixes issue [#3294](https://github.com/naturalcrit/homebrewery/issues/3294)
* [x] Upgrade Font-Awesome to v6.51
* [x] Allow downloaded files to be uploaded via {{openSans **NEW {{fa,fa-plus-square}} → FROM UPLOAD {{fa,fa-upload}}**}}
##### G-Ambatte
* [x] Fix an edge case crash with empty documents
Fixes issue [#3315](https://github.com/naturalcrit/homebrewery/issues/3315)
* [x] Brews on the user page can be searched by tag; clicking a tag adds it to the filter
Fixes issue [#3164](https://github.com/naturalcrit/homebrewery/issues/3164)
* [x] Add *DiceFont* icons {{df,d20-20}} `{{df,icon-name}}`
##### abquintic
* [x] Fix ^super^ and ^^sub^^ highlighting in the text editor
* [x] Add new syntax for multiline Definition Lists:
```
Term
::Definition 1
::Definition 2
with more text
```
produces:
Term
::Definition 1
::Definition 2
with more text
Fixes issue [#2340](https://github.com/naturalcrit/homebrewery/issues/2340)
##### RKuerten :
* [x] Fix monster stat block backgrounds on print page
Fixes issue [#3275](https://github.com/naturalcrit/homebrewery/issues/3275)
* [x] Added new text editor theme: "Darkvision".
##### calculuschild, G-Ambatte, 5e-Cleric
* [x] Codebase and UI cleanup
}}
\page
### Friday 21/2/2024 - v3.11.0
{{taskList
##### Gazook89
* [x] Brew view count no longer increases when viewed by owner
Fixes issue [#3037](https://github.com/naturalcrit/homebrewery/issues/3037)
* [x] Small tweak to PHB H3 sizing
Fixes issue [#2989](https://github.com/naturalcrit/homebrewery/issues/2989)
* [x] Add **Fold/Unfold All** {{fas,fa-compress-alt}} / {{fas,fa-expand-alt}} buttons to editor bar
Fixes issue [#2965](https://github.com/naturalcrit/homebrewery/issues/2965)
##### G-Ambatte
* [x] Share link added to Editor Access error page
Fixes issue [#3086](https://github.com/naturalcrit/homebrewery/issues/3086)
* [x] Add Darkbrewery theme to Editor theme selector {{fas,fa-palette}}
Fixes issue [#3034](https://github.com/naturalcrit/homebrewery/issues/3034)
* [x] Fix Firefox prints with alternating blank pages
Fixes issue [#3115](https://github.com/naturalcrit/homebrewery/issues/3115)
* [x] Admin page working again
Fixes issue [#2657](https://github.com/naturalcrit/homebrewery/issues/2657)
##### 5e-Cleric
* [x] Fix indenting issue with Monster Blocks and italics in Class Feature
Fixes issues [#527](https://github.com/naturalcrit/homebrewery/issues/527),
[#3247](https://github.com/naturalcrit/homebrewery/issues/3247)
* [x] Allow CSS vars in curly syntax to be formatted as strings using single quotes
`{{--customVar:"'a string'"}}`
Fixes issue [#3066](https://github.com/naturalcrit/homebrewery/issues/3066)
* [x] Add *Elderberry Inn* icons {{ei,action}} `{{ei,icon-name}}`
Fixes issue [#3171](https://github.com/naturalcrit/homebrewery/issues/3171)
* [x] New {{openSans **{{fas,fa-keyboard}} FONTS** }} snippets!
Fixes issue [#3171](https://github.com/naturalcrit/homebrewery/issues/3171)
* [x] New page now opens in a new tab
##### abquintic (new contributor!)
* [x] Add ^super^ `^abc^` and ^^sub^^ `^^abc^^` syntax.
Fixes issue [#2171](https://github.com/naturalcrit/homebrewery/issues/2171)
* [x] Add HTML tag assignment to curly syntax `{{tag=value}}`
Fixes issue [1488](https://github.com/naturalcrit/homebrewery/issues/1488)
* [x] {{openSans **Brew → Clone to New**}} now clones tags
Fixes issue [1488](https://github.com/naturalcrit/homebrewery/issues/1488)
##### calculuschild
* [x] Better error messages for "Out of Google Drive Storage" and "Not logged in to edit"
Fixes issues [2510](https://github.com/naturalcrit/homebrewery/issues/2510),
[2975](https://github.com/naturalcrit/homebrewery/issues/2975)
* [x] Brew Variables
}}
\
{{wide
### Brew Variable Syntax
You may already be familiar with `[link](url)` and `![image](url)` synax. We have expanded this to include a third `$[variable](text)` syntax. All three of these syntaxes now share a common set of features:
{{varSyntaxTable
| syntax | description |
|:-------|-------------|
| `[var]:content` | Assigns a variable (must start on a line by itself, and ends at the next blank line) |
| `[var](content)` | Assigns a variable and outputs it (can be inline) |
| `[var]` | Outputs the variable contents as a link, if formatted as a valid link |
| `![var]` | Outputs as an image, if formatted as a valid image |
| `$[var]` | Outputs as Markdown |
| `$[var1 + var2 - 2 * var3]` | Performs math operations and outputs result if all variables are valid numbers |
}}
}}
{{wide,margin-top:0,margin-bottom:0
### Examples
}}
{{wide,columns:2,margin-top:0,margin-bottom:0
```
[first]: Bob
[last]: Jones
My name is $[first] $[last].
```
\column
[first]: Bob
[last]: Jones
My name is $[first] $[last].
}}
{{wide,columns:2,margin-top:0,margin-bottom:0
```
[myTable]:
| h1 | h2 |
|----|----|
| c1 | c2 |
Here is my table:
$[myTable]
```
\column
[myTable]:
| h1 | h2 |
|----|----|
| c1 | c2 |
Here is my table:
$[myTable]
}}
{{wide,columns:2,margin-top:0,margin-bottom:0
```
There are $[TableNum] tables total.
#### Table $[TableNum](1): Horses
#### Table $[TableNum]($[TableNum + 1]): Cows
```
\column
There are $[TableNum] tables in this document. *(note: final value of `$[TableNum]` gets hoisted up if available)*
#### Table $[TableNum](1): Horses
#### Table $[TableNum]($[TableNum + 1]): Cows
}}
\page
### Friday 13/10/2023 - v3.10.0
{{taskList
##### G-Ambatte
* [x] Fix user preferred save location being ignored
Fixes issue [#2993](https://github.com/naturalcrit/homebrewery/issues/2993)
* [x] Fix crash to white screen when starting new brews while not signed in
Fixes issue [#2999](https://github.com/naturalcrit/homebrewery/issues/2999)
* [x] Fix FreeBSD install script
Fixes issue [#3005](https://github.com/naturalcrit/homebrewery/issues/3005)
* [x] Fix *"This brew has been changed on another device"* triggering when manually saving during auto-save
Fixes issue [#2641](https://github.com/naturalcrit/homebrewery/issues/2641)
* [x] Fix Firefox different column-flow behavior
Fixes issue [#2982](https://github.com/naturalcrit/homebrewery/issues/2982)
* [x] Fix brew titles being mis-sorted on user page
Fixes issue [#2775](https://github.com/naturalcrit/homebrewery/issues/2775)
* [x] Text Editor themes now available via new drop-down
Fixes issue [#362](https://github.com/naturalcrit/homebrewery/issues/362)
##### 5e-Cleric
* [x] New {{openSans **PHB → {{fas,fa-quote-right}} QUOTE** }} snippet for V3!
Fixes issue [#2920](https://github.com/naturalcrit/homebrewery/issues/2920)
* [x] Several updates and fixes to FAQ and Welcome page
Fixes issue [#2729](https://github.com/naturalcrit/homebrewery/issues/2729),
[#2787](https://github.com/naturalcrit/homebrewery/issues/2787)
##### Gazook89
* [x] Add syntax highlighting for Definition Lists <code>:\:</code>
}}
### Thursday 17/08/2023 - v3.9.2
{{taskList
##### Calculuschild
* [x] Fix links to certain old Google Drive files
Fixes issue [#2917](https://github.com/naturalcrit/homebrewery/issues/2917)
##### G-Ambatte
* [x] Menus now open on click, and internally consistent
Fixes issue [#2702](https://github.com/naturalcrit/homebrewery/issues/2702), [#2782](https://github.com/naturalcrit/homebrewery/issues/2782)
* [x] Add smarter footer snippet
Fixes issue [#2289](https://github.com/naturalcrit/homebrewery/issues/2289)
* [x] Add sanitization in Style editor
Fixes issue [#1437](https://github.com/naturalcrit/homebrewery/issues/1437)
* [x] Rework class table snippets to remove unnecessary randomness
Fixes issue [#2964](https://github.com/naturalcrit/homebrewery/issues/2964)
* [x] Add User Page link to Google Drive file for file owners, add icons for additional storage locations
Fixes issue [#2954](https://github.com/naturalcrit/homebrewery/issues/2954)
* [x] Add default save location selection to Account Page
Fixes issue [#2943](https://github.com/naturalcrit/homebrewery/issues/2943)
##### 5e-Cleric
* [x] Exclude cover pages from Table of Content generation (editing on mobile is still not recommended)
Fixes issue [#2920](https://github.com/naturalcrit/homebrewery/issues/2920)
##### Gazook89
* [x] Adjustments to improve mobile viewing
}}
### Wednesday 28/06/2023 - v3.9.1
{{taskList
@@ -539,8 +124,6 @@ Fixes issue [#2790](https://github.com/naturalcrit/homebrewery/issues/2790)
Fixes issue [#2784](https://github.com/naturalcrit/homebrewery/issues/2784)
}}
\page
### Wednesday 12/04/2023 - v3.8.0
{{taskList
@@ -602,6 +185,8 @@ Fixes issues [#2731](https://github.com/naturalcrit/homebrewery/issues/2731)
}}
\page
### Monday 13/03/2023 - v3.7.2
{{taskList
@@ -682,11 +267,7 @@ Fixes issues [#2603](https://github.com/naturalcrit/homebrewery/issues/2603)
* [x] Add message to refresh the browser if the user is missing an update to the Homebrewery
Fixes issues [#2583](https://github.com/naturalcrit/homebrewery/issues/2583)
}}
\page
{{taskList
##### G-Ambatte
* [x] Auto-compile Themes CSS on development server
@@ -696,6 +277,7 @@ Fixes issues [#2583](https://github.com/naturalcrit/homebrewery/issues/2583)
* [x] Fix cloned brews inheriting the parent view count
}}
\page
### Friday 23/12/2022 - v3.5.0
{{taskList
@@ -1652,7 +1234,7 @@ myStyle {color: black}
### Sunday, 29/05/2016 - v2.1.0
- Finally added a syntax for doing spell lists. A bit in-depth about why this took so long. Essentially I'm running out of syntax to use in stardard Markdown. There are too many unique elements in the PHB-style to be mapped. I solved this earlier by stacking certain elements together (eg. an `<hr>` before a `blockquote` turns it into moster state block), but those are getting unweildly. I would like to simply wrap these in `div`s with classes, but unfortunately Markdown stops processing when within HTML blocks. To get around this I wrote my own override to the Markdown parser and lexer to process Markdown within a simple div class wrapper. This should open the door for more unique syntaxes in the future. Big step!
- Override Ctrl+P (and cmd+P) to launch to the print page. Many people try to just print either the editing or share page to get a PDF. While this dones;t make much sense, I do get a ton of issues about it. So now if you try to do this, it'll just bring you imediately to the print page. Everybody wins!
- The onboarding flow has also been confusing a few users (Homepage new save edit page). If you edit the Homepage text now, a Call to Action to save your work will pop-up.
- The onboarding flow has also been confusing a few users (Homepage -> new -> save -> edit page). If you edit the Homepage text now, a Call to Action to save your work will pop-up.
- Added a 'Recently Edited' and 'Recently Viewed' nav item to the edit and share page respectively. Each will remember the last 8 items you edited or viewed and when you viewed it. Makes use of the new title attribute of brews to easy navigatation.
- Paragraphs now indent properly after lists (thanks u/slitjen!)
@@ -1779,4 +1361,4 @@ Massive changelog incoming:
* Added `phb.standalone.css` plus a build system for creating it
* Added page numbers and footer text
* Page accent now flips each page
* Page accent now flips each page

View File

@@ -1,6 +1,7 @@
require('./brewCleanup.less');
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
const request = require('superagent');

View File

@@ -1,6 +1,7 @@
require('./brewCompress.less');
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
const request = require('superagent');

View File

@@ -1,6 +1,7 @@
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
require('./combobox.less');
const Combobox = createClass({

View File

@@ -1,29 +0,0 @@
// 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,8 +1,9 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./brewRenderer.less');
const React = require('react');
const { useState, useRef, useEffect } = React;
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
const Markdown = require('naturalcrit/markdown.js');
@@ -12,223 +13,244 @@ const ErrorBar = require('./errorBar/errorBar.jsx');
const RenderWarnings = require('homebrewery/renderWarnings/renderWarnings.jsx');
const NotificationPopup = require('./notificationPopup/notificationPopup.jsx');
const Frame = require('react-frame-component').default;
const dedent = require('dedent-tabs').default;
const { printCurrentBrew } = require('../../../shared/helpers.js');
const DOMPurify = require('dompurify');
const purifyConfig = { FORCE_BODY: true, SANITIZE_DOM: false };
const Themes = require('themes/themes.json');
const PAGE_HEIGHT = 1056;
const PPR_THRESHOLD = 50;
const INITIAL_CONTENT = dedent`
<!DOCTYPE html><html><head>
<link href="//use.fontawesome.com/releases/v6.5.1/css/all.css" rel="stylesheet" type="text/css" />
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" />
<link href='/homebrew/bundle.css' type="text/css" rel='stylesheet' />
<base target=_blank>
</head><body style='overflow: hidden'><div></div></body></html>`;
const BrewRenderer = createClass({
displayName : 'BrewRenderer',
getDefaultProps : function() {
return {
text : '',
style : '',
renderer : 'legacy',
theme : '5ePHB',
lang : '',
errors : []
};
},
getInitialState : function() {
let pages;
if(this.props.renderer == 'legacy') {
pages = this.props.text.split('\\page');
} else {
pages = this.props.text.split(/^\\page$/gm);
}
//v=====----------------------< Brew Page Component >---------------------=====v//
const BrewPage = (props)=>{
props = {
contents : '',
index : 0,
...props
};
const cleanText = props.contents; //DOMPurify.sanitize(props.contents, purifyConfig);
return <div className={props.className} id={`p${props.index + 1}`} >
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
</div>;
};
return {
viewablePageNumber : 0,
height : 0,
isMounted : false,
pages : pages,
usePPR : pages.length >= PPR_THRESHOLD,
visibility : 'hidden',
initialContent : `<!DOCTYPE html><html><head>
<link href="//use.fontawesome.com/releases/v5.15.1/css/all.css" rel="stylesheet" />
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" />
<link href='/homebrew/bundle.css' rel='stylesheet' />
<base target=_blank>
</head><body style='overflow: hidden'><div></div></body></html>`
};
},
height : 0,
lastRender : <div></div>,
//v=====--------------------< Brew Renderer Component >-------------------=====v//
const renderedPages = [];
let rawPages = [];
componentWillUnmount : function() {
window.removeEventListener('resize', this.updateSize);
},
const BrewRenderer = (props)=>{
props = {
text : '',
style : '',
renderer : 'legacy',
theme : '5ePHB',
lang : '',
errors : [],
currentEditorPage : 0,
themeBundle : {},
...props
};
componentDidUpdate : function(prevProps) {
if(prevProps.text !== this.props.text) {
let pages;
if(this.props.renderer == 'legacy') {
pages = this.props.text.split('\\page');
} else {
pages = this.props.text.split(/^\\page$/gm);
}
this.setState({
pages : pages,
usePPR : pages.length >= PPR_THRESHOLD
});
}
},
const [state, setState] = useState({
viewablePageNumber : 0,
height : PAGE_HEIGHT,
isMounted : false,
visibility : 'hidden',
});
updateSize : function() {
this.setState({
height : this.refs.main.parentNode.clientHeight,
});
},
const mainRef = useRef(null);
if(props.renderer == 'legacy') {
rawPages = props.text.split('\\page');
} else {
rawPages = props.text.split(/^\\page$/gm);
}
useEffect(()=>{ // Unmounting steps
return ()=>{window.removeEventListener('resize', updateSize);};
}, []);
const updateSize = ()=>{
setState((prevState)=>({
...prevState,
height : mainRef.current.parentNode.clientHeight,
}));
};
const handleScroll = (e)=>{
handleScroll : function(e){
const target = e.target;
setState((prevState)=>({
...prevState,
viewablePageNumber : Math.floor(target.scrollTop / target.scrollHeight * rawPages.length)
this.setState((prevState)=>({
viewablePageNumber : Math.floor(target.scrollTop / target.scrollHeight * prevState.pages.length)
}));
};
},
const isInView = (index)=>{
if(!state.isMounted)
return false;
shouldRender : function(pageText, index){
if(!this.state.isMounted) return false;
if(index == props.currentEditorPage) //Already rendered before this step
return false;
const viewIndex = this.state.viewablePageNumber;
if(index == viewIndex - 3) return true;
if(index == viewIndex - 2) return true;
if(index == viewIndex - 1) return true;
if(index == viewIndex) return true;
if(index == viewIndex + 1) return true;
if(index == viewIndex + 2) return true;
if(index == viewIndex + 3) return true;
if(Math.abs(index - state.viewablePageNumber) <= 3)
return true;
//Check for style tages
if(pageText.indexOf('<style>') !== -1) return true;
return false;
};
},
const renderPageInfo = ()=>{
return <div className='pageInfo' ref={mainRef}>
sanitizeScriptTags : function(content) {
return content
.replace(/<script/ig, '&lt;script')
.replace(/<\/script>/ig, '&lt;/script&gt;');
},
renderPageInfo : function(){
return <div className='pageInfo' ref='main'>
<div>
{props.renderer}
{this.props.renderer}
</div>
<div>
{state.viewablePageNumber + 1} / {rawPages.length}
{this.state.viewablePageNumber + 1} / {this.state.pages.length}
</div>
</div>;
};
},
const renderDummyPage = (index)=>{
renderPPRmsg : function(){
if(!this.state.usePPR) return;
return <div className='ppr_msg'>
Partial Page Renderer is enabled, because your brew is so large. May affect rendering.
</div>;
},
renderDummyPage : function(index){
return <div className='phb page' id={`p${index + 1}`} key={index}>
<i className='fas fa-spinner fa-spin' />
</div>;
};
},
const renderStyle = ()=>{
const cleanStyle = props.style; //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: `${themeStyles} \n\n <style> ${cleanStyle} </style>` }} />;
};
renderStyle : function() {
if(!this.props.style) return;
const cleanStyle = this.sanitizeScriptTags(this.props.style);
//return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `<style>@layer styleTab {\n${this.sanitizeScriptTags(this.props.style)}\n} </style>` }} />;
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `<style> ${cleanStyle} </style>` }} />;
},
const renderPage = (pageText, index)=>{
if(props.renderer == 'legacy') {
const html = MarkdownLegacy.render(pageText);
return <BrewPage className='page phb' index={index} key={index} contents={html} />;
} else {
renderPage : function(pageText, index){
const cleanPageText = this.sanitizeScriptTags(pageText);
if(this.props.renderer == 'legacy')
return <div className='phb page' id={`p${index + 1}`} dangerouslySetInnerHTML={{ __html: MarkdownLegacy.render(cleanPageText) }} key={index} />;
else {
pageText += `\n\n&nbsp;\n\\column\n&nbsp;`; //Artificial column break at page end to emulate column-fill:auto (until `wide` is used, when column-fill:balance will reappear)
const html = Markdown.render(pageText, index);
return <BrewPage className='page' index={index} key={index} contents={html} />;
return (
<div className='page' id={`p${index + 1}`} key={index} >
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: Markdown.render(cleanPageText) }} />
</div>
);
}
};
},
const renderPages = ()=>{
if(props.errors && props.errors.length)
return renderedPages;
if(rawPages.length != renderedPages.length) // Re-render all pages when page count changes
renderedPages.length = 0;
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first
renderedPages[props.currentEditorPage] = renderPage(rawPages[props.currentEditorPage], props.currentEditorPage);
_.forEach(rawPages, (page, index)=>{
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
renderedPages[index] = renderPage(page, index); // Render any page not yet rendered, but only re-render those in PPR range
renderPages : function(){
if(this.state.usePPR){
return _.map(this.state.pages, (page, index)=>{
if(this.shouldRender(page, index) && typeof window !== 'undefined'){
return this.renderPage(page, index);
} else {
return this.renderDummyPage(index);
}
});
}
if(this.props.errors && this.props.errors.length) return this.lastRender;
this.lastRender = _.map(this.state.pages, (page, index)=>{
if(typeof window !== 'undefined') {
return this.renderPage(page, index);
} else {
return this.renderDummyPage(index);
}
});
return renderedPages;
};
return this.lastRender;
},
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"
frameDidMount : function(){ //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
updateSize();
window.addEventListener('resize', updateSize);
renderPages(); //Make sure page is renderable before showing
setState((prevState)=>({
...prevState,
this.updateSize();
window.addEventListener('resize', this.updateSize);
this.renderPages(); //Make sure page is renderable before showing
this.setState({
isMounted : true,
visibility : 'visible'
}));
});
}, 100);
};
},
const emitClick = ()=>{ // Allow clicks inside iFrame to interact with dropdowns, etc. from outside
emitClick : function(){
// console.log('iFrame clicked');
if(!window || !document) return;
document.dispatchEvent(new MouseEvent('click'));
};
},
return (
<>
{/*render dummy page while iFrame is mounting.*/}
{!state.isMounted
? <div className='brewRenderer' onScroll={handleScroll}>
<div className='pages'>
{renderDummyPage(1)}
render : function(){
//render in iFrame so broken code doesn't crash the site.
//Also render dummy page while iframe is mounting.
const rendererPath = this.props.renderer == 'V3' ? 'V3' : 'Legacy';
const themePath = this.props.theme ?? '5ePHB';
const baseThemePath = Themes[rendererPath][themePath].baseTheme;
return (
<React.Fragment>
{!this.state.isMounted
? <div className='brewRenderer' onScroll={this.handleScroll}>
<div className='pages' ref='pages'>
{this.renderDummyPage(1)}
</div>
</div>
</div>
: null}
: null}
<ErrorBar errors={props.errors} />
<div className='popups'>
<RenderWarnings />
<NotificationPopup />
</div>
<Frame id='BrewRenderer' initialContent={this.state.initialContent}
style={{ width: '100%', height: '100%', visibility: this.state.visibility }}
contentDidMount={this.frameDidMount}
onClick={()=>{this.emitClick();}}
>
<div className={'brewRenderer'}
onScroll={this.handleScroll}
style={{ height: this.state.height }}>
{/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
style={{ width: '100%', height: '100%', visibility: state.visibility }}
contentDidMount={frameDidMount}
onClick={()=>{emitClick();}}
>
<div className={'brewRenderer'}
onScroll={handleScroll}
onKeyDown={handleControlKeys}
tabIndex={-1}
style={{ height: state.height }}>
{/* Apply CSS from Style tab and render pages from Markdown tab */}
{state.isMounted
&&
<>
{renderStyle()}
<div className='pages' lang={`${props.lang || 'en'}`}>
{renderPages()}
</div>
</>
}
</div>
</Frame>
{renderPageInfo()}
</>
);
};
<ErrorBar errors={this.props.errors} />
<div className='popups'>
<RenderWarnings />
<NotificationPopup />
</div>
<link href={`/themes/${rendererPath}/Blank/style.css`} rel='stylesheet'/>
{baseThemePath &&
<link href={`/themes/${rendererPath}/${baseThemePath}/style.css`} rel='stylesheet'/>
}
<link href={`/themes/${rendererPath}/${themePath}/style.css`} rel='stylesheet'/>
{/* Apply CSS from Style tab and render pages from Markdown tab */}
{this.state.isMounted
&&
<>
{this.renderStyle()}
<div className='pages' ref='pages' lang={`${this.props.lang || 'en'}`}>
{this.renderPages()}
</div>
</>
}
</div>
</Frame>
{this.renderPageInfo()}
{this.renderPPRmsg()}
</React.Fragment>
);
}
});
module.exports = BrewRenderer;

View File

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

View File

@@ -2,6 +2,7 @@ require('./errorBar.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
const ErrorBar = createClass({
displayName : 'ErrorBar',

View File

@@ -1,45 +1,84 @@
require('./notificationPopup.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
import Dialog from '../../../components/dialog.jsx';
const cx = require('classnames'); //Unused variable
const DISMISS_KEY = 'dismiss_notification12-04-23';
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
const NotificationPopup = ()=>{
return <Dialog className='notificationPopup' dismissKey={DISMISS_KEY} closeText={DISMISS_BUTTON} >
<div className='header'>
<i className='fas fa-info-circle info'></i>
<h3>Notice</h3>
<small>This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:</small>
</div>
<ul>
<li key='psa'>
<em>Don't store IMAGES in Google Drive</em><br />
Google Drive is not an image service, and will block images from being used
in brews if they get more views than expected. Google has confirmed they won't fix
this, so we recommend you look for another image hosting service such as imgur, ImgBB or Google Photos.
</li>
const NotificationPopup = createClass({
displayName : 'NotificationPopup',
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>Broken default logo on <b>CoverPage</b> </em> <br />
If you have used the Cover Page snippet and notice the Naturalcrit
logo is showing as a broken image, this is due to some small tweaks
of this BETA feature. To fix the logo in your cover page, rename
the image link <b>"/assets/naturalCritLogoRed.svg"</b>. Remember
that any snippet marked "BETA" may have a similar change in the
future as we encounter any bugs or reworks.
</li>
<li key='googleDriveFolder'>
<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
on your Google Drive with *.txt files inside, <em>do not delete it</em>!
We cannot help you recover files that you have deleted from your own
Google Drive.
</li>
<li key='googleDriveFolder'>
<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
on your Google Drive with *.txt files inside, <em>do not delete it</em>!
We cannot help you recover files that you have deleted from your own
Google Drive.
</li>
<li key='faq'>
<em>Protect your work! </em> <br />
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>
</ul>
</Dialog>;
};
<li key='faq'>
<em>Protect your work! </em> <br />
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({
notifications : _.mapValues(this.notifications, (fn)=>{ return fn(); }) //Convert notification functions into their return text value
});
},
dismiss : function(){
localStorage.setItem(DISMISS_KEY, true);
this.checkNotifications();
},
render : function(){
if(_.isEmpty(this.state.notifications)) return null;
return <div className='notificationPopup'>
<i className='fas fa-times dismiss' onClick={this.dismiss}/>
<i className='fas fa-info-circle info' />
<div className='header'>
<h3>Notice</h3>
<small>This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:</small>
</div>
<ul>{_.values(this.state.notifications)}</ul>
</div>;
}
});
module.exports = NotificationPopup;

View File

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

View File

@@ -5,14 +5,11 @@ const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
const dedent = require('dedent-tabs').default;
const Markdown = require('../../../shared/naturalcrit/markdown.js');
const CodeEditor = require('naturalcrit/codeEditor/codeEditor.jsx');
const SnippetBar = require('./snippetbar/snippetbar.jsx');
const MetadataEditor = require('./metadataEditor/metadataEditor.jsx');
const EDITOR_THEME_KEY = 'HOMEBREWERY-EDITOR-THEME';
const SNIPPETBAR_HEIGHT = 25;
const DEFAULT_STYLE_TEXT = dedent`
/*=======--- Example CSS styling ---=======*/
@@ -37,20 +34,15 @@ const Editor = createClass({
onMetaChange : ()=>{},
reportError : ()=>{},
editorTheme : 'default',
renderer : 'legacy'
renderer : 'legacy'
};
},
getInitialState : function() {
return {
editorTheme : this.props.editorTheme,
view : 'text' //'text', 'style', 'meta'
view : 'text' //'text', 'style', 'meta'
};
},
editor : React.createRef(null),
codeEditor : React.createRef(null),
isText : function() {return this.state.view == 'text';},
isStyle : function() {return this.state.view == 'style';},
isMeta : function() {return this.state.view == 'meta';},
@@ -59,13 +51,6 @@ const Editor = createClass({
this.updateEditorSize();
this.highlightCustomMarkdown();
window.addEventListener('resize', this.updateEditorSize);
const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
if(editorTheme) {
this.setState({
editorTheme : editorTheme
});
}
},
componentWillUnmount : function() {
@@ -83,15 +68,15 @@ const Editor = createClass({
},
updateEditorSize : function() {
if(this.codeEditor.current) {
let paneHeight = this.editor.current.parentNode.clientHeight;
paneHeight -= SNIPPETBAR_HEIGHT;
this.codeEditor.current.codeMirror.setSize(null, paneHeight);
if(this.refs.codeEditor) {
let paneHeight = this.refs.main.parentNode.clientHeight;
paneHeight -= SNIPPETBAR_HEIGHT + 1;
this.refs.codeEditor.codeMirror.setSize(null, paneHeight);
}
},
handleInject : function(injectText){
this.codeEditor.current?.injectText(injectText, false);
this.refs.codeEditor?.injectText(injectText, false);
},
handleViewChange : function(newView){
@@ -102,7 +87,7 @@ const Editor = createClass({
},
getCurrentPage : function(){
const lines = this.props.brew.text.split('\n').slice(0, this.codeEditor.current.getCursorPosition().line + 1);
const lines = this.props.brew.text.split('\n').slice(0, this.refs.codeEditor.getCursorPosition().line + 1);
return _.reduce(lines, (r, line)=>{
if(
(this.props.renderer == 'legacy' && line.indexOf('\\page') !== -1)
@@ -114,9 +99,9 @@ const Editor = createClass({
},
highlightCustomMarkdown : function(){
if(!this.codeEditor.current) return;
if(!this.refs.codeEditor) return;
if(this.state.view === 'text') {
const codeMirror = this.codeEditor.current.codeMirror;
const codeMirror = this.refs.codeEditor.codeMirror;
codeMirror.operation(()=>{ // Batch CodeMirror styling
//reset custom text styles
@@ -153,45 +138,9 @@ const Editor = createClass({
codeMirror.addLineClass(lineNumber, 'text', 'columnSplit');
}
// definition lists
if(line.includes('::')){
if(/^:*$/.test(line) == true){ return };
const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/ymd; // the `d` flag, for match indices, throws an ESLint error.
let match;
while ((match = regex.exec(line)) != null){
codeMirror.markText({ line: lineNumber, ch: match.indices[0][0] }, { line: lineNumber, ch: match.indices[0][1] }, { className: 'dl-highlight' });
codeMirror.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' });
codeMirror.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
const ddIndex = match.indices[2][0];
let colons = /::/g;
let colonMatches = colons.exec(match[2]);
if(colonMatches !== null){
codeMirror.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight'} )
}
}
}
// Subscript & Superscript
if(line.includes('^')) {
let startIndex = line.indexOf('^');
const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy;
const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy;
while (startIndex >= 0) {
superRegex.lastIndex = subRegex.lastIndex = startIndex;
let isSuper = false;
let match = subRegex.exec(line) || superRegex.exec(line);
if (match) {
isSuper = !subRegex.lastIndex;
codeMirror.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' });
}
startIndex = line.indexOf('^', Math.max(startIndex + 1, subRegex.lastIndex, superRegex.lastIndex));
}
}
// Highlight injectors {style}
if(line.includes('{') && line.includes('}')){
const regex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gm;
const regex = /(?:^|[^{\n])({(?=((?::(?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':{}\s]*)*))\2})/gm;
let match;
while ((match = regex.exec(line)) != null) {
codeMirror.markText({ line: lineNumber, ch: line.indexOf(match[1]) }, { line: lineNumber, ch: line.indexOf(match[1]) + match[1].length }, { className: 'injection' });
@@ -199,7 +148,7 @@ const Editor = createClass({
}
// Highlight inline spans {{content}}
if(line.includes('{{') && line.includes('}}')){
const regex = /{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *|}}/g;
const regex = /{{(?=((?::(?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':{}\s]*)*))\1 *|}}/g;
let match;
let blockCount = 0;
while ((match = regex.exec(line)) != null) {
@@ -218,39 +167,11 @@ const Editor = createClass({
// Highlight block divs {{\n Content \n}}
let endCh = line.length+1;
const match = line.match(/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/);
const match = line.match(/^ *{{(?=((?::(?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':{}\s]*)*))\1 *$|^ *}}$/);
if(match)
endCh = match.index+match[0].length;
codeMirror.markText({ line: lineNumber, ch: 0 }, { line: lineNumber, ch: endCh }, { className: 'block' });
}
// Emojis
if(line.match(/:[^\s:]+:/g)) {
let startIndex = line.indexOf(':');
const emojiRegex = /:[^\s:]+:/gy;
while (startIndex >= 0) {
emojiRegex.lastIndex = startIndex;
let match = emojiRegex.exec(line);
if (match) {
let tokens = Markdown.marked.lexer(match[0]);
tokens = tokens[0].tokens.filter(t => t.type == 'emoji')
if (!tokens.length)
return;
let startPos = { line: lineNumber, ch: match.index };
let endPos = { line: lineNumber, ch: match.index + match[0].length };
// Iterate over conflicting marks and clear them
var marks = codeMirror.findMarks(startPos, endPos);
marks.forEach(function(marker) {
marker.clear();
});
codeMirror.markText(startPos, endPos, { className: 'emoji' });
}
startIndex = line.indexOf(':', Math.max(startIndex + 1, emojiRegex.lastIndex));
}
}
}
});
});
@@ -305,23 +226,23 @@ const Editor = createClass({
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);
let currentY = this.refs.codeEditor.codeMirror.getScrollInfo().top;
let targetY = this.refs.codeEditor.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);
this.refs.codeEditor.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);
targetY = this.refs.codeEditor.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');
this.refs.codeEditor.codeMirror.scrollTo(null, targetY); // Scroll any remaining difference
this.refs.codeEditor.setCursorPosition({ line: targetLine + 1, ch: 0 });
this.refs.codeEditor.codeMirror.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
clearInterval(incrementalScroll);
}
}, 10);
@@ -331,14 +252,7 @@ const Editor = createClass({
//Called when there are changes to the editor's dimensions
update : function(){
this.codeEditor.current?.updateSize();
},
updateEditorTheme : function(newTheme){
window.localStorage.setItem(EDITOR_THEME_KEY, newTheme);
this.setState({
editorTheme : newTheme
});
this.refs.codeEditor?.updateSize();
},
//Called by CodeEditor after document switch, so Snippetbar can refresh UndoHistory
@@ -350,25 +264,23 @@ const Editor = createClass({
if(this.isText()){
return <>
<CodeEditor key='codeEditor'
ref={this.codeEditor}
ref='codeEditor'
language='gfm'
view={this.state.view}
value={this.props.brew.text}
onChange={this.props.onTextChange}
editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent} />
rerenderParent={this.rerenderParent}/>
</>;
}
if(this.isStyle()){
return <>
<CodeEditor key='codeEditor'
ref={this.codeEditor}
ref='codeEditor'
language='css'
view={this.state.view}
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT}
onChange={this.props.onStyleChange}
enableFolding={false}
editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent} />
</>;
}
@@ -381,35 +293,26 @@ const Editor = createClass({
<MetadataEditor
metadata={this.props.brew}
onChange={this.props.onMetaChange}
reportError={this.props.reportError}
userThemes={this.props.userThemes}/>
reportError={this.props.reportError}/>
</>;
}
},
redo : function(){
return this.codeEditor.current?.redo();
return this.refs.codeEditor?.redo();
},
historySize : function(){
return this.codeEditor.current?.historySize();
return this.refs.codeEditor?.historySize();
},
undo : function(){
return this.codeEditor.current?.undo();
},
foldCode : function(){
return this.codeEditor.current?.foldAllCode();
},
unfoldCode : function(){
return this.codeEditor.current?.unfoldAllCode();
return this.refs.codeEditor?.undo();
},
render : function(){
return (
<div className='editor' ref={this.editor}>
<div className='editor' ref='main'>
<SnippetBar
brew={this.props.brew}
view={this.state.view}
@@ -420,13 +323,8 @@ const Editor = createClass({
theme={this.props.brew.theme}
undo={this.undo}
redo={this.redo}
foldCode={this.foldCode}
unfoldCode={this.unfoldCode}
historySize={this.historySize()}
currentEditorTheme={this.state.editorTheme}
updateEditorTheme={this.updateEditorTheme}
snippetBundle={this.props.snippetBundle}
cursorPos={this.codeEditor.current?.getCursorPosition() || {}} />
cursorPos={this.refs.codeEditor?.getCursorPosition() || {}} />
{this.renderEditor()}
</div>

View File

@@ -1,105 +1,65 @@
@import 'themes/codeMirror/customEditorStyles.less';
.editor {
.editor{
position : relative;
width : 100%;
.codeEditor {
.codeEditor{
height : 100%;
.pageLine {
.pageLine{
background : #33333328;
border-top : #333399 solid 1px;
border-top : #339 solid 1px;
}
.editor-page-count {
float : right;
.editor-page-count{
color : grey;
float : right;
}
.columnSplit {
font-style : italic;
color : grey;
background-color : fade(#229999, 15%);
border-bottom : #229999 solid 1px;
.columnSplit{
font-style : italic;
color : grey;
background-color : fade(#299, 15%);
border-bottom : #299 solid 1px;
}
.define {
&:not(.term):not(.definition) {
font-weight : bold;
color : #949494;
background : #E5E5E5;
border-radius : 3px;
}
&.term { color : rgb(96, 117, 143); }
&.definition { color : rgb(97, 57, 178); }
}
.block:not(.cm-comment) {
.block:not(.cm-comment){
color : purple;
font-weight : bold;
color : purple;
//font-style: italic;
}
.inline-block:not(.cm-comment) {
.inline-block:not(.cm-comment){
color : red;
font-weight : bold;
color : red;
//font-style: italic;
}
.injection:not(.cm-comment) {
font-weight : bold;
.injection:not(.cm-comment){
color : green;
font-weight : bold;
}
.emoji:not(.cm-comment) {
margin-left : 2px;
color : #360034;
background : #ffc8ff;
border-radius : 6px;
font-weight : bold;
padding-bottom : 1px;
outline-offset : -2px;
outline : solid 2px #ff96fc;
}
.superscript:not(.cm-comment) {
font-weight : bold;
color : goldenrod;
vertical-align : super;
font-size : 0.9em;
}
.subscript:not(.cm-comment) {
font-weight : bold;
color : rgb(123, 123, 15);
vertical-align : sub;
font-size : 0.9em;
}
.dl-highlight {
&.dl-colon-highlight {
font-weight : bold;
color : #949494;
background : #E5E5E5;
border-radius : 3px;
}
&.dt-highlight { color : rgb(96, 117, 143); }
&.dd-highlight { color : rgb(97, 57, 178); }
}
}
.brewJump {
position : absolute;
right : 20px;
bottom : 20px;
z-index : 1000000;
display : flex;
align-items : center;
justify-content : center;
width : 30px;
height : 30px;
cursor : pointer;
background-color : @teal;
.tooltipLeft('Jump to brew page');
.brewJump{
position : absolute;
background-color : @teal;
cursor : pointer;
width : 30px;
height : 30px;
display : flex;
align-items : center;
bottom : 20px;
right : 20px;
z-index : 1000000;
justify-content : center;
.tooltipLeft("Jump to brew page");
}
.editorToolbar {
position : absolute;
top : 5px;
left : 50%;
z-index : 9;
font-size : 13px;
color : black;
span { padding : 2px 5px; }
.editorToolbar{
position: absolute;
top: 5px;
left: 50%;
color: black;
font-size: 13px;
z-index: 9;
span {
padding: 2px 5px;
}
}
}

View File

@@ -3,12 +3,12 @@ require('./metadataEditor.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
const request = require('../../utils/request-middleware.js');
const Nav = require('naturalcrit/nav/nav.jsx');
const Combobox = require('client/components/combobox.jsx');
const StringArrayEditor = require('../stringArrayEditor/stringArrayEditor.jsx');
const Themes = require('themes/themes.json');
const validations = require('./validations.js');
@@ -99,7 +99,7 @@ const MetadataEditor = createClass({
if(renderer == 'legacy')
this.props.metadata.theme = '5ePHB';
}
this.props.onChange(this.props.metadata, 'renderer');
this.props.onChange(this.props.metadata);
},
handlePublish : function(val){
this.props.onChange({
@@ -111,7 +111,7 @@ const MetadataEditor = createClass({
handleTheme : function(theme){
this.props.metadata.renderer = theme.renderer;
this.props.metadata.theme = theme.path;
this.props.onChange(this.props.metadata, 'theme');
this.props.onChange(this.props.metadata);
},
handleLanguage : function(languageCode){
@@ -192,41 +192,37 @@ const MetadataEditor = createClass({
renderThemeDropdown : function(){
if(!global.enable_themes) return;
const mergedThemes = _.merge(Themes, this.props.userThemes);
const listThemes = (renderer)=>{
return _.map(_.values(mergedThemes[renderer]), (theme)=>{
const preview = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownPreview.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>
return _.map(_.values(Themes[renderer]), (theme)=>{
return <div className='item' key={''} onClick={()=>this.handleTheme(theme)} title={''}>
{`${theme.renderer} : ${theme.name}`}
<img src={`/themes/${theme.renderer}/${theme.path}/dropdownTexture.png`}/>
<div className='preview'>
<h6>{theme.name} preview</h6>
<img src={preview}/>
<h6>{`${theme.name}`} preview</h6>
<img src={`/themes/${theme.renderer}/${theme.path}/dropdownPreview.png`}/>
</div>
</div>;
});
};
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}` };
const currentTheme = Themes[`${_.upperFirst(this.props.metadata.renderer)}`][this.props.metadata.theme];
let dropdown;
if(currentRenderer == 'legacy') {
if(this.props.metadata.renderer == 'legacy') {
dropdown =
<Nav.dropdown className='disabled value' trigger='disabled'>
<div> {`Themes are not supported in the Legacy Renderer`} <i className='fas fa-caret-down'></i> </div>
<div>
{`Themes are not supported in the Legacy Renderer`} <i className='fas fa-caret-down'></i>
</div>
</Nav.dropdown>;
} else {
dropdown =
<Nav.dropdown className='value' trigger='click'>
<div> {currentTheme.author ?? _.upperFirst(currentRenderer)} : {currentTheme.name} <i className='fas fa-caret-down'></i> </div>
{listThemes(currentRenderer)}
<div>
{`${_.upperFirst(currentTheme.renderer)} : ${currentTheme.name}`} <i className='fas fa-caret-down'></i>
</div>
{/*listThemes('Legacy')*/}
{listThemes('V3')}
</Nav.dropdown>;
}

View File

@@ -2,7 +2,7 @@
.metadataEditor{
position : absolute;
z-index : 5;
z-index : 10000;
box-sizing : border-box;
width : 100%;
padding : 25px;
@@ -191,13 +191,6 @@
color : white;
}
}
.navDropdown .item > p {
width: 45%;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1.1em;
}
.navDropdown {
box-shadow : 0px 5px 10px rgba(0, 0, 0, 0.3);
position : absolute;
@@ -237,23 +230,14 @@
&:hover > .preview {
opacity: 1;
}
.texture-container {
position: absolute;
width: 100%;
height: 100%;
min-height: 100%;
top: 0;
left: 0;
overflow: hidden;
> img {
mask-image : linear-gradient(90deg, transparent, black 20%);
-webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
position : absolute;
right : 0;
top : 0px;
width : 50%;
min-height : 100%;
}
>img {
mask-image : linear-gradient(90deg, transparent, black 20%);
-webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
position : absolute;
right : 0;
top : 0px;
width : 50%;
height : 100%;
}
}
}

View File

@@ -1,4 +1,3 @@
/*eslint max-lines: ["warn", {"max": 250, "skipBlankLines": true, "skipComments": true}]*/
require('./snippetbar.less');
const React = require('react');
const createClass = require('create-react-class');
@@ -6,6 +5,9 @@ const _ = require('lodash');
const cx = require('classnames');
//Import all themes
const Themes = require('themes/themes.json');
const ThemeSnippets = {};
ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js');
ThemeSnippets['V3_5ePHB'] = require('themes/V3/5ePHB/snippets.js');
@@ -13,8 +15,6 @@ ThemeSnippets['V3_5eDMG'] = require('themes/V3/5eDMG/snippets.js');
ThemeSnippets['V3_Journal'] = require('themes/V3/Journal/snippets.js');
ThemeSnippets['V3_Blank'] = require('themes/V3/Blank/snippets.js');
const EditorThemes = require('build/homebrew/codeMirror/editorThemes.json');
const execute = function(val, props){
if(_.isFunction(val)) return val(props);
return val;
@@ -24,69 +24,69 @@ const Snippetbar = createClass({
displayName : 'SnippetBar',
getDefaultProps : function() {
return {
brew : {},
view : 'text',
onViewChange : ()=>{},
onInject : ()=>{},
onToggle : ()=>{},
showEditButtons : true,
renderer : 'legacy',
undo : ()=>{},
redo : ()=>{},
historySize : ()=>{},
foldCode : ()=>{},
unfoldCode : ()=>{},
updateEditorTheme : ()=>{},
cursorPos : {},
snippetBundle : []
brew : {},
view : 'text',
onViewChange : ()=>{},
onInject : ()=>{},
onToggle : ()=>{},
showEditButtons : true,
renderer : 'legacy',
undo : ()=>{},
redo : ()=>{},
historySize : ()=>{},
cursorPos : {}
};
},
getInitialState : function() {
return {
renderer : this.props.renderer,
themeSelector : false,
snippets : []
renderer : this.props.renderer,
snippets : []
};
},
componentDidMount : async function() {
const snippets = this.compileSnippets();
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({
snippets : snippets
});
},
componentDidUpdate : async function(prevProps) {
if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) {
const snippets = this.compileSnippets();
if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme) {
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({
snippets : snippets
});
}
},
mergeCustomizer : function(oldValue, newValue, key) {
mergeCustomizer : function(valueA, valueB, key) {
if(key == 'snippets') {
const result = _.reverse(_.unionBy(_.reverse(newValue), _.reverse(oldValue), 'name')); // Join snippets together, with preference for the child theme over the parent theme
const result = _.reverse(_.unionBy(_.reverse(valueB), _.reverse(valueA), 'name')); // Join snippets together, with preference for the current theme over the base theme
return _.filter(result, 'gen'); //Only keep snippets with a 'gen' property.
}
},
compileSnippets : function() {
let compiledSnippets = [];
compileSnippets : function(rendererPath, themePath, snippets) {
let compiledSnippets = snippets;
const baseSnippetsPath = Themes[rendererPath][themePath].baseSnippets;
let oldSnippets = _.keyBy(compiledSnippets, 'groupName');
const objB = _.keyBy(compiledSnippets, 'groupName');
for (let snippets of this.props.snippetBundle) {
if(typeof(snippets) == 'string') // load staticThemes as needed; they were sent as just a file name
snippets = ThemeSnippets[snippets];
const newSnippets = _.keyBy(_.cloneDeep(snippets), 'groupName');
compiledSnippets = _.values(_.mergeWith(oldSnippets, newSnippets, this.mergeCustomizer));
oldSnippets = _.keyBy(compiledSnippets, 'groupName');
if(baseSnippetsPath) {
const objA = _.keyBy(_.cloneDeep(ThemeSnippets[`${rendererPath}_${baseSnippetsPath}`]), 'groupName');
compiledSnippets = _.values(_.mergeWith(objA, objB, this.mergeCustomizer));
compiledSnippets = this.compileSnippets(rendererPath, baseSnippetsPath, _.cloneDeep(compiledSnippets));
} else {
const objA = _.keyBy(_.cloneDeep(ThemeSnippets[`${rendererPath}_Blank`]), 'groupName');
compiledSnippets = _.values(_.mergeWith(objA, objB, this.mergeCustomizer));
}
return compiledSnippets;
},
@@ -95,33 +95,6 @@ const Snippetbar = createClass({
this.props.onInject(injectedText);
},
toggleThemeSelector : function(e){
if(e.target.tagName != 'SELECT'){
this.setState({
themeSelector : !this.state.themeSelector
});
}
},
changeTheme : function(e){
if(e.target.value == this.props.currentEditorTheme) return;
this.props.updateEditorTheme(e.target.value);
this.setState({
showThemeSelector : false,
});
},
renderThemeSelector : function(){
return <div className='themeSelector'>
<select value={this.props.currentEditorTheme} onChange={this.changeTheme} >
{EditorThemes.map((theme, key)=>{
return <option key={key} value={theme}>{theme}</option>;
})}
</select>
</div>;
},
renderSnippetGroups : function(){
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
@@ -141,22 +114,6 @@ const Snippetbar = createClass({
renderEditorButtons : function(){
if(!this.props.showEditButtons) return;
let foldButtons;
if(this.props.view == 'text'){
foldButtons =
<>
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
onClick={this.props.foldCode} >
<i className='fas fa-compress-alt' />
</div>
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
onClick={this.props.unfoldCode} >
<i className='fas fa-expand-alt' />
</div>
</>;
}
return <div className='editors'>
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
onClick={this.props.undo} >
@@ -166,14 +123,6 @@ const Snippetbar = createClass({
onClick={this.props.redo} >
<i className='fas fa-redo' />
</div>
<div className='divider'></div>
{foldButtons}
<div className={`editorTool editorTheme ${this.state.themeSelector ? 'active' : ''}`}
onClick={this.toggleThemeSelector} >
<i className='fas fa-palette' />
{this.state.themeSelector && this.renderThemeSelector()}
</div>
<div className='divider'></div>
<div className={cx('text', { selected: this.props.view === 'text' })}
onClick={()=>this.props.onViewChange('text')}>
@@ -224,7 +173,7 @@ const SnippetGroup = createClass({
return _.map(snippets, (snippet)=>{
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}>
<i className={snippet.icon} />
<span className='name'title={snippet.name}>{snippet.name}</span>
<span className='name'>{snippet.name}</span>
{snippet.experimental && <span className='beta'>beta</span>}
{snippet.subsnippets && <>
<i className='fas fa-caret-right'></i>
@@ -247,4 +196,5 @@ const SnippetGroup = createClass({
</div>
</div>;
},
});

View File

@@ -1,192 +1,144 @@
@import (less) './client/icons/customIcons.less';
@import (less) '././././themes/fonts/5e/fonts.less';
.snippetBar {
.snippetBar{
@menuHeight : 25px;
position : relative;
height : @menuHeight;
color : black;
background-color : #DDDDDD;
.editors {
background-color : #ddd;
.editors{
position : absolute;
display : flex;
top : 0px;
right : 0px;
display : flex;
justify-content : space-between;
height : @menuHeight;
& > div {
width : @menuHeight;
width : 125px;
justify-content : space-between;
&>div{
height : @menuHeight;
width : @menuHeight;
cursor : pointer;
line-height : @menuHeight;
text-align : center;
cursor : pointer;
&:hover,&.selected { background-color : #999999; }
&.text {
&:hover,&.selected{
background-color : #999;
}
&.text{
.tooltipLeft('Brew Editor');
}
&.style {
&.style{
.tooltipLeft('Style Editor');
}
&.meta {
&.meta{
.tooltipLeft('Properties');
}
&.undo {
&.undo{
.tooltipLeft('Undo');
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
&.active{
color : black;
}
}
&.redo {
&.redo{
.tooltipLeft('Redo');
font-size : 0.75em;
color : grey;
&.active { color : inherit; }
}
&.foldAll {
.tooltipLeft('Fold All');
font-size : 0.75em;
color : inherit;
}
&.unfoldAll {
.tooltipLeft('Unfold All');
font-size : 0.75em;
color : inherit;
}
&.editorTheme {
.tooltipLeft('Editor Themes');
font-size : 0.75em;
color : black;
&.active {
position : relative;
background-color : #999999;
&.active{
color : black;
}
}
&.divider {
width : 5px;
background : linear-gradient(currentColor, currentColor) no-repeat center/1px 100%;
&:hover { background-color : inherit; }
background: linear-gradient(#000, #000) no-repeat center/1px 100%;
width: 5px;
&:hover{
background-color: inherit;
}
}
}
.themeSelector {
position : absolute;
top : 25px;
right : 0;
z-index : 10;
display : flex;
align-items : center;
justify-content : center;
width : 170px;
height : inherit;
background-color : inherit;
}
}
.snippetBarButton {
display : inline-block;
.snippetBarButton{
height : @menuHeight;
padding : 0px 5px;
font-size : 0.625em;
font-weight : 800;
line-height : @menuHeight;
display : inline-block;
padding : 0px 5px;
font-weight : 800;
font-size : 0.625em;
text-transform : uppercase;
cursor : pointer;
&:hover, &.selected { background-color : #999999; }
i {
&:hover, &.selected{
background-color : #999;
}
i{
vertical-align : middle;
margin-right : 3px;
font-size : 1.4em;
vertical-align : middle;
}
}
.toggleMeta {
position : absolute;
top : 0px;
right : 0px;
border-left : 1px solid black;
.tooltipLeft('Edit Brew Properties');
.toggleMeta{
position : absolute;
top : 0px;
right : 0px;
border-left : 1px solid black;
.tooltipLeft("Edit Brew Properties");
}
.snippetGroup {
border-right : 1px solid currentColor;
&:hover {
& > .dropdown { visibility : visible; }
.snippetGroup{
border-right : 1px solid black;
&:hover{
&>.dropdown{
visibility : visible;
}
}
.dropdown {
.dropdown{
position : absolute;
top : 100%;
z-index : 1000;
padding : 0px;
margin-left : -5px;
visibility : hidden;
background-color : #DDDDDD;
.snippet {
position : relative;
z-index : 1000;
margin-left : -5px;
padding : 0px;
background-color : #ddd;
.snippet{
position: relative;
.animate(background-color);
display : flex;
align-items : center;
min-width : max-content;
padding : 5px;
font-size : 10px;
cursor : pointer;
.animate(background-color);
i {
height : 1.2em;
font-size : 10px;
i{
margin-right : 8px;
font-size : 1.2em;
min-width: 25px;
text-align: center;
& ~ i {
margin-right : 0;
margin-left : 5px;
}
/* Fonts */
&.font {
height : auto;
&::before {
font-size : 1em;
content : 'ABC';
}
&.OpenSans {font-family : 'OpenSans';}
&.CodeBold {font-family : 'CodeBold';}
&.CodeLight {font-family : 'CodeLight';}
&.ScalySansRemake {font-family : 'ScalySansRemake';}
&.BookInsanityRemake {font-family : 'BookInsanityRemake';}
&.MrEavesRemake {font-family : 'MrEavesRemake';}
&.SolberaImitationRemake {font-family : 'SolberaImitationRemake';}
&.ScalySansSmallCapsRemake {font-family : 'ScalySansSmallCapsRemake';}
&.WalterTurncoat {font-family : 'WalterTurncoat';}
&.Lato {font-family : 'Lato';}
&.Courier {font-family : 'Courier';}
&.NodestoCapsCondensed {font-family : 'NodestoCapsCondensed';}
&.Overpass {font-family : 'Overpass';}
&.Davek {font-family : 'Davek';}
&.Iokharic {font-family : 'Iokharic';}
&.Rellanic {font-family : 'Rellanic';}
&.TimesNewRoman {font-family : 'Times New Roman';}
height : 1.2em;
&~i{
margin-right: 0;
margin-left: 5px;
}
}
.name { margin-right : auto; }
.name {
margin-right : auto;
}
.beta {
align-self : center;
padding : 4px 6px;
margin-left : 5px;
font-family : monospace;
line-height : 1em;
color : white;
padding : 4px 6px;
line-height : 1em;
margin-left : 5px;
align-self : center;
background : grey;
border-radius : 12px;
font-family : monospace;
}
&:hover {
background-color : #999999;
& > .dropdown {
&:hover{
background-color : #999;
&>.dropdown{
visibility : visible;
&.side {
top : 0%;
left : 100%;
margin-left : 0;
box-shadow : -1px 1px 2px 0px #999999;
left: 100%;
top: 0%;
margin-left:0;
box-shadow: -1px 1px 2px 0px #999;
}
}
}
}
}
}
}
}

View File

@@ -10,6 +10,7 @@ const UserPage = require('./pages/userPage/userPage.jsx');
const SharePage = require('./pages/sharePage/sharePage.jsx');
const NewPage = require('./pages/newPage/newPage.jsx');
const ErrorPage = require('./pages/errorPage/errorPage.jsx');
const PrintPage = require('./pages/printPage/printPage.jsx');
const AccountPage = require('./pages/accountPage/accountPage.jsx');
const WithRoute = (props)=>{
@@ -66,18 +67,20 @@ const Homebrew = createClass({
<Router location={this.props.url}>
<div className='homebrew'>
<Routes>
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} />
<Route path='/edit/:id' element={<WithRoute el={EditPage} 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} userThemes={this.props.userThemes}/>} />
<Route path='/new' element={<WithRoute el={NewPage} userThemes={this.props.userThemes}/> } />
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={this.props.brew} />} />
<Route path='/new' element={<WithRoute el={NewPage}/>} />
<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='/print' element={<WithRoute el={PrintPage} />} />
<Route path='/changelog' element={<WithRoute el={SharePage} brew={this.props.brew} />} />
<Route path='/faq' element={<WithRoute el={SharePage} brew={this.props.brew} />} />
<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} uiItems={this.props.brew.uiItems} />} />
<Route path='/legacy' element={<WithRoute el={HomePage} 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>
</div>
</Router>
@@ -85,4 +88,15 @@ 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

@@ -15,23 +15,6 @@
}
&.listPage .content {
overflow-y : scroll;
&::-webkit-scrollbar {
width: 20px;
&:horizontal{
height: 20px;
width:auto;
}
&-thumb {
background: linear-gradient(90deg, #d3c1af 15px, #00000000 15px);
&:horizontal{
background: linear-gradient(0deg, #d3c1af 15px, #00000000 15px);
}
}
&-corner {
visibility: hidden;
}
}
}
}
}

View File

@@ -21,11 +21,10 @@ const ErrorNavItem = createClass({
this.props.parent.setState(state);
};
const error = this.props.error;
const response = error.response;
const status = response.status;
const HBErrorCode = response.body?.HBErrorCode;
const message = response.body?.message;
const error = this.props.error;
const response = error.response;
const status = response.status;
const message = response.body?.message;
let errMsg = '';
try {
errMsg += `${error.toString()}\n\n`;
@@ -41,9 +40,7 @@ const ErrorNavItem = createClass({
{message ?? 'Conflict: please refresh to get latest changes'}
</div>
</Nav.item>;
}
if(status === 412) {
} else if(status === 412) {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
@@ -52,36 +49,6 @@ const ErrorNavItem = createClass({
</Nav.item>;
}
if(HBErrorCode === '04') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
You are no longer signed in as an author of
this brew! Were you signed out from a different
window? Visit our log in page, then try again!
<br></br>
<a target='_blank' rel='noopener noreferrer'
href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
<div className='confirm'>
Sign In
</div>
</a>
<div className='deny'>
Not Now
</div>
</div>
</Nav.item>;
}
if(response.body?.errors?.[0].reason == 'storageQuotaExceeded') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Can't save because your Google Drive seems to be full!
</div>
</Nav.item>;
}
if(response.req.url.match(/^\/api.*Google.*$/m)){
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
@@ -90,7 +57,6 @@ const ErrorNavItem = createClass({
expired! Visit our log in page to sign out
and sign back in with Google,
then try saving again!
<br></br>
<a target='_blank' rel='noopener noreferrer'
href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
<div className='confirm'>
@@ -104,18 +70,6 @@ const ErrorNavItem = createClass({
</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'>
Oops!
<div className='errorContainer'>

View File

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

View File

@@ -1,4 +1,6 @@
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const dedent = require('dedent-tabs').default;
const Nav = require('naturalcrit/nav/nav.jsx');

View File

@@ -1,5 +1,6 @@
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const Moment = require('moment');
const Nav = require('naturalcrit/nav/nav.jsx');

View File

@@ -1,351 +1,272 @@
@import 'naturalcrit/styles/colors.less';
@import "naturalcrit/styles/colors.less";
@navbarHeight : 28px;
@keyframes pinkColoring {
0% { color : pink; }
50% { color : pink; }
75% { color : red; }
100% { color : pink; }
0% {color : pink;}
50% {color : pink;}
75% {color : red;}
100% {color : pink;}
}
@keyframes glideDropDown {
0% {
background-color : #333333;
opacity : 0;
transform : translate(0px, -100%);
}
100% {
background-color : #333333;
opacity : 1;
transform : translate(0px, 0px);
}
}
.homebrew nav {
background-color : #333333;
.navContent {
position : relative;
z-index : 2;
display : flex;
justify-content : space-between;
}
.navSection {
display : flex;
align-items : center;
&:last-child .navItem { border-left : 1px solid #666666; }
}
// "NaturalCrit" logo
.navLogo {
display : block;
margin-top : 0px;
margin-right : 8px;
margin-left : 8px;
color : white;
text-decoration : none;
.homebrewLogo {
.animate(color);
font-family : CodeBold;
font-size : 12px;
color : white;
div {
margin-top : 2px;
margin-bottom : -2px;
}
&:hover {
.name { color : @orange; }
svg { fill : @orange; }
}
svg {
height : 13px;
margin-right : 0.2em;
cursor : pointer;
fill : white;
}
span.name {
font-family : 'CodeLight';
font-size : 15px;
span.crit { font-family : 'CodeBold'; }
small {
font-family : 'Open Sans';
font-size : 0.3em;
font-weight : 800;
text-transform : uppercase;
}
color : @blue;
}
}
.navItem {
#backgroundColorsHover;
.animate(background-color);
padding : 8px 12px;
font-size : 10px;
font-weight : 800;
line-height : 13px;
color : white;
text-decoration : none;
text-transform : uppercase;
cursor : pointer;
background-color : #333333;
i {
float : right;
margin-left : 5px;
font-size : 13px;
}
&.patreon {
border-right : 1px solid #666666;
border-left : 1px solid #666666;
&:hover i { color : red; }
i {
color : pink;
.animate(color);
animation-name : pinkColoring;
animation-duration : 2s;
}
}
&.editTitle { // this is not needed at all currently - you used to be able to edit the title via the navbar.
padding : 2px 12px;
input {
width : 250px;
padding : 2px;
margin : 0;
font-family : 'Open Sans', sans-serif;
font-size : 12px;
font-weight : 800;
color : white;
text-align : center;
background-color : transparent;
border : 1px solid @blue;
outline : none;
}
.charCount {
display : inline-block;
margin-left : 8px;
color : #666666;
text-align : right;
vertical-align : bottom;
&.max { color : @red; }
}
}
&.brewTitle {
flex-grow : 1;
.editTitle.navItem {
padding : 2px 12px;
input {
font-family : "Open Sans", sans-serif;
font-size : 12px;
font-weight : 800;
color : white;
width : 250px;
margin : 0;
padding : 2px;
text-align : center;
text-transform : initial;
color : white;
border : 1px solid @blue;
outline : none;
background-color : transparent;
}
// "The Homebrewery" logo
&.homebrewLogo {
.charCount {
display : inline-block;
margin-left : 8px;
text-align : right;
vertical-align : bottom;
color : #666;
&.max {
color : @red;
}
}
}
.brewTitle.navItem {
font-size : 12px;
font-weight : 800;
height : 100%;
text-align : center;
text-transform : initial;
color : white;
background-color : transparent;
flex-grow : 1;
}
.save-menu {
.dropdown {
z-index : 1000;
}
.navItem i.fa-power-off {
color : red;
&.active {
color : rgb(0, 182, 52);
filter : drop-shadow(0 0 2px rgba(0, 182, 52, 0.765));
}
}
}
.patreon.navItem {
border-right : 1px solid #666;
border-left : 1px solid #666;
&:hover i {
color : red;
}
i {
.animate(color);
font-family : 'CodeBold';
font-size : 12px;
color : white;
div {
margin-top : 2px;
margin-bottom : -2px;
}
&:hover { color : @blue; }
}
&.metadata {
position : relative;
display : flex;
flex-grow : 1;
align-items : center;
height : 100%;
padding : 0;
i { margin-right : 10px;}
.window {
position : absolute;
bottom : 0;
left : 50%;
z-index : -1;
display : flex;
flex-flow : row wrap;
align-content : baseline;
justify-content : flex-start;
width : 440px;
max-height : ~'calc(100vh - 28px)';
padding : 0 10px 5px;
margin : 0 auto;
background-color : #333333;
border : 3px solid #444444;
border-top : unset;
border-radius : 0 0 5px 5px;
box-shadow : inset 0 7px 9px -7px #111111;
transition : transform 0.4s, opacity 0.4s;
&.active {
opacity : 1;
transform : translateX(-50%) translateY(100%);
}
&.inactive {
opacity : 0;
transform : translateX(-50%) translateY(0%);
}
.row {
display : flex;
flex-flow : row wrap;
width : 100%;
h4 {
box-sizing : border-box;
display : block;
flex-basis : 20%;
flex-grow : 1;
min-width : 76px;
padding : 5px 0;
color : #BBBBBB;
text-align : center;
}
p {
flex-basis : 80%;
flex-grow : 1;
padding : 5px 0;
font-family : 'Open Sans', sans-serif;
font-size : 10px;
font-weight : normal;
text-transform : initial;
.tag {
display : inline-block;
padding : 2px;
margin : 2px 2px;
background-color : #444444;
border : 2px solid grey;
border-radius : 5px;
}
a.userPageLink {
color : white;
text-decoration : none;
&:hover { text-decoration : underline; }
}
}
&:nth-of-type(even) { background-color : #555555; }
}
}
}
&.warning {
position : relative;
color : white;
background-color : @orange;
&:hover > .dropdown { visibility : visible; }
.dropdown {
position : absolute;
top : 28px;
left : 0;
z-index : 10000;
box-sizing : border-box;
display : block;
width : 100%;
padding : 13px 5px;
text-align : center;
visibility : hidden;
background-color : #333333;
}
}
&.account {
min-width : 100px;
&.username { text-transform : none;}
animation-name : pinkColoring;
animation-duration : 2s;
color : pink;
}
}
.navDropdownContainer {
.recent.navDropdownContainer {
position : relative;
.navDropdown {
position: absolute;
top: 28px;
right: 0px;
z-index: 10000;
width: max-content;
min-width:100%;
max-height: calc(100vh - 28px);
overflow: hidden auto;
display: flex;
flex-direction: column;
align-items: flex-end;
.navItem {
position : relative;
display : flex;
justify-content : space-between;
align-items : center;
width : 100%;
border : 1px solid #888888;
border-bottom : 0;
animation-name : glideDropDown;
animation-duration : 0.4s;
}
}
&.recent {
position : relative;
.navDropdown .navItem {
#backgroundColorsHover;
.animate(background-color);
position : relative;
box-sizing : border-box;
display : block;
max-width : 15em;
max-height : ~'calc(100vh - 28px)';
padding : 8px 5px 13px;
overflow : hidden auto;
color : white;
text-decoration : none;
background-color : #333333;
border-top : 1px solid #888888;
scrollbar-color : #666666 #333333;
scrollbar-width : thin;
.clear {
position : absolute;
top : 50%;
right : 0;
display : none;
width : 20px;
height : 100%;
background-color : #333333;
border-radius : 3px;
opacity : 70%;
transform : translateY(-50%);
&:hover { opacity : 100%; }
i {
width : 100%;
height : 100%;
margin : 0;
font-size : 10px;
text-align : center;
}
}
&:hover {
background-color : @blue;
.clear {
display : grid;
place-content : center;
}
}
.title {
display : inline-block;
width : 100%;
overflow : hidden auto;
text-overflow : ellipsis;
white-space : nowrap;
}
.time {
position : absolute;
right : 2px;
bottom : 2px;
font-size : 0.7em;
color : #888888;
}
&.header {
box-sizing : border-box;
display : block;
padding : 5px 0;
color : #BBBBBB;
text-align : center;
background-color : #333333;
border-top : 1px solid #888888;
&:nth-of-type(1) { background-color : darken(@teal, 20%); }
&:nth-of-type(2) { background-color : darken(@purple, 30%); }
}
}
}
}
}
.navDropdown .navItem {
overflow : hidden auto;
max-height : ~"calc(100vh - 28px)";
scrollbar-color : #666 #333;
scrollbar-width : thin;
// this should likely be refactored into .navDropdownContainer
.save-menu {
.dropdown { z-index : 1000; }
.navItem i.fa-power-off {
color : red;
&.active {
color : rgb(0, 182, 52);
filter : drop-shadow(0 0 2px rgba(0, 182, 52, 0.765));
#backgroundColorsHover;
.animate(background-color);
position : relative;
display : block;
overflow : clip;
box-sizing : border-box;
padding : 8px 5px 13px;
text-decoration : none;
color : white;
border-top : 1px solid #888;
background-color : #333;
.clear {
position : absolute;
top : 50%;
right : 0;
display : none;
width : 20px;
height : 100%;
transform : translateY(-50%);
opacity : 70%;
border-radius : 3px;
background-color : #333;
&:hover {
opacity : 100%;
}
i {
font-size : 10px;
width : 100%;
height : 100%;
margin : 0;
text-align : center;
}
}
&:hover {
background-color : @blue;
.clear {
display : grid;
place-content : center;
}
}
.title {
display : inline-block;
overflow : hidden;
width : 100%;
white-space : nowrap;
text-overflow : ellipsis;
}
.time {
font-size : 0.7em;
position : absolute;
right : 2px;
bottom : 2px;
color : #888;
}
&.header {
display : block;
box-sizing : border-box;
padding : 5px 0;
text-align : center;
color : #BBB;
border-top : 1px solid #888;
background-color : #333;
&:nth-of-type(1) {
background-color : darken(@teal, 20%);
}
&:nth-of-type(2) {
background-color : darken(@purple, 30%);
}
}
}
}
.metadata.navItem {
position : relative;
display : flex;
align-items : center;
height : 100%;
padding : 0;
flex-grow : 1;
i {
margin-right : 10px;
}
.window {
position : absolute;
z-index : -1;
bottom : 0;
left : 50%;
display : flex;
justify-content : flex-start;
width : 440px;
max-height : ~"calc(100vh - 28px)";
margin : 0 auto;
padding : 0 10px 5px;
transition : transform 0.4s, opacity 0.4s;
border : 3px solid #444;
border-top : unset;
border-radius : 0 0 5px 5px;
background-color : #333;
box-shadow : inset 0 7px 9px -7px #111;
flex-flow : row wrap;
align-content : baseline;
&.active {
transform : translateX(-50%) translateY(100%);
opacity : 1;
}
&.inactive {
transform : translateX(-50%) translateY(0%);
opacity : 0;
}
.row {
display : flex;
width : 100%;
flex-flow : row wrap;
h4 {
display : block;
box-sizing : border-box;
min-width : 76px;
padding : 5px 0;
text-align : center;
color : #BBB;
flex-basis : 20%;
flex-grow : 1;
}
p {
font-family : "Open Sans", sans-serif;
font-size : 10px;
font-weight : normal;
padding : 5px 0;
text-transform : initial;
flex-basis : 80%;
flex-grow : 1;
.tag {
display : inline-block;
margin : 2px 2px;
padding : 2px;
border : 2px solid grey;
border-radius : 5px;
background-color : #444;
}
a.userPageLink {
text-decoration : none;
color : white;
&:hover {
text-decoration : underline;
}
}
}
&:nth-of-type(even) {
background-color : #555;
}
}
}
}
.warning.navItem {
position : relative;
color : white;
background-color : @orange;
&:hover > .dropdown {
visibility : visible;
}
.dropdown {
position : absolute;
z-index : 10000;
top : 28px;
left : 0;
display : block;
visibility : hidden;
box-sizing : border-box;
width : 100%;
padding : 13px 5px;
text-align : center;
background-color : #333;
}
}
.account.navItem {
min-width : 100px;
}
.account.username.navItem {
text-transform : none;
}
}

View File

@@ -1,64 +1,11 @@
const React = require('react');
const _ = require('lodash');
const Nav = require('naturalcrit/nav/nav.jsx');
const { splitTextStyleAndMetadata } = require('../../../shared/helpers.js'); // Importing the function from helpers.js
const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style';
const METAKEY = 'homebrewery-new-meta';
const NewBrew = ()=>{
const handleFileChange = (e)=>{
const file = e.target.files[0];
if(file) {
const reader = new FileReader();
reader.onload = (e)=>{
const fileContent = e.target.result;
const newBrew = {
text : fileContent,
style : ''
};
if(fileContent.startsWith('```metadata')) {
splitTextStyleAndMetadata(newBrew); // Modify newBrew directly
localStorage.setItem(BREWKEY, newBrew.text);
localStorage.setItem(STYLEKEY, newBrew.style);
localStorage.setItem(METAKEY, JSON.stringify(_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang'])));
window.location.href = '/new';
} else {
alert('This file is invalid, please, enter a valid file');
}
};
reader.readAsText(file);
}
};
return (
<Nav.dropdown>
<Nav.item
className='new'
color='purple'
icon='fa-solid fa-plus-square'>
new
</Nav.item>
<Nav.item
className='fromBlank'
href='/new'
newTab={true}
color='purple'
icon='fa-solid fa-file'>
from blank
</Nav.item>
<Nav.item
className='fromFile'
color='purple'
icon='fa-solid fa-upload'
onClick={()=>{ document.getElementById('uploadTxt').click(); }}>
<input id='uploadTxt' className='newFromLocal' type='file' onChange={handleFileChange} style={{ display: 'none' }} />
from file
</Nav.item>
</Nav.dropdown>
);
module.exports = function(props){
return <Nav.item
href='/new'
color='purple'
icon='fas fa-plus-square'>
new
</Nav.item>;
};
module.exports = NewBrew;

View File

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

View File

@@ -1,7 +1,9 @@
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
const Nav = require('naturalcrit/nav/nav.jsx');
const MAX_URL_SIZE = 2083;
const MAIN_URL = 'https://www.reddit.com/r/UnearthedArcana/submit?selftext=true';

View File

@@ -1,82 +1,74 @@
const React = require('react');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
const moment = require('moment');
const UIPage = require('../basePages/uiPage/uiPage.jsx');
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const Account = require('../../navbar/account.navitem.jsx');
const NewBrew = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx');
const NaturalCritIcon = require('naturalcrit/svg/naturalcrit.svg.jsx');
let SAVEKEY = '';
const AccountPage = createClass({
displayName : 'AccountPage',
getDefaultProps : function() {
return {
brew : {},
uiItems : {}
};
},
getInitialState : function() {
return {
uiItems : this.props.uiItems
};
},
const AccountPage = (props)=>{
// destructure props and set state for save location
const { accountDetails, brew } = props;
const [saveLocation, setSaveLocation] = React.useState('');
renderNavItems : function() {
return <Navbar>
<Nav.section>
<NewBrew />
<HelpNavItem />
<RecentNavItem />
<Account />
</Nav.section>
</Navbar>;
},
// initialize save location from local storage based on user id
React.useEffect(()=>{
if(!saveLocation && accountDetails.username) {
SAVEKEY = `HOMEBREWERY-DEFAULT-SAVE-LOCATION-${accountDetails.username}`;
// if no SAVEKEY in local storage, default save location to Google Drive if user has Google account.
let saveLocation = window.localStorage.getItem(SAVEKEY);
saveLocation = saveLocation ?? (accountDetails.googleId ? 'GOOGLE-DRIVE' : 'HOMEBREWERY');
setActiveSaveLocation(saveLocation);
}
}, []);
renderUiItems : function() {
return <>
<div className='dataGroup'>
<h1>Account Information <i className='fas fa-user'></i></h1>
<p><strong>Username: </strong> {this.props.uiItems.username || 'No user currently logged in'}</p>
<p><strong>Last Login: </strong> {moment(this.props.uiItems.issued).format('dddd, MMMM Do YYYY, h:mm:ss a ZZ') || '-'}</p>
</div>
<div className='dataGroup'>
<h3>Homebrewery Information <NaturalCritIcon /></h3>
<p><strong>Brews on Homebrewery: </strong> {this.props.uiItems.mongoCount}</p>
</div>
<div className='dataGroup'>
<h3>Google Information <i className='fab fa-google-drive'></i></h3>
<p><strong>Linked to Google: </strong> {this.props.uiItems.googleId ? 'YES' : 'NO'}</p>
{this.props.uiItems.googleId &&
<p>
<strong>Brews on Google Drive: </strong> {this.props.uiItems.googleCount ?? <>Unable to retrieve files - <a href='https://github.com/naturalcrit/homebrewery/discussions/1580'>follow these steps to renew your Google credentials.</a></>}
</p>
}
</div>
</>;
},
const setActiveSaveLocation = (newSelection)=>{
if(saveLocation === newSelection) return;
window.localStorage.setItem(SAVEKEY, newSelection);
setSaveLocation(newSelection);
};
// todo: should this be a set of radio buttons (well styled) since it's either/or choice?
const renderSaveLocationButton = (name, key, shouldRender = true)=>{
if(!shouldRender) return null;
return (
<button className={saveLocation === key ? 'active' : ''} onClick={()=>{setActiveSaveLocation(key);}}>
{name}
</button>
);
};
// render the entirety of the account page content
const renderAccountPage = ()=>{
return (
<>
<div className='dataGroup'>
<h1>Account Information <i className='fas fa-user'></i></h1>
<p><strong>Username: </strong>{accountDetails.username || 'No user currently logged in'}</p>
<p><strong>Last Login: </strong>{moment(accountDetails.issued).format('dddd, MMMM Do YYYY, h:mm:ss a ZZ') || '-'}</p>
</div>
<div className='dataGroup'>
<h3>Homebrewery Information <NaturalCritIcon /></h3>
<p><strong>Brews on Homebrewery: </strong>{accountDetails.mongoCount}</p>
</div>
<div className='dataGroup'>
<h3>Google Information <i className='fab fa-google-drive'></i></h3>
<p><strong>Linked to Google: </strong>{accountDetails.googleId ? 'YES' : 'NO'}</p>
{accountDetails.googleId && (
<p>
<strong>Brews on Google Drive: </strong>{accountDetails.googleCount ?? (
<>
Unable to retrieve files - <a href='https://github.com/naturalcrit/homebrewery/discussions/1580'>follow these steps to renew your Google credentials.</a>
</>
)}
</p>
)}
</div>
<div className='dataGroup'>
<h4>Default Save Location</h4>
{renderSaveLocationButton('Homebrewery', 'HOMEBREWERY')}
{renderSaveLocationButton('Google Drive', 'GOOGLE-DRIVE', accountDetails.googleId)}
</div>
</>
);
};
// return the account page inside the base layout wrapper (with navbar etc).
return (
<UIPage brew={brew}>
{renderAccountPage()}
</UIPage>);
};
render : function(){
return <UIPage brew={this.props.brew}>
{this.renderUiItems()}
</UIPage>;
}
});
module.exports = AccountPage;

View File

@@ -1,11 +1,12 @@
require('./brewItem.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
const moment = require('moment');
const request = require('../../../../utils/request-middleware.js');
const googleDriveIcon = require('../../../../googleDrive.svg');
const homebreweryIcon = require('../../../../thumbnail.png');
const dedent = require('dedent-tabs').default;
const BrewItem = createClass({
@@ -18,8 +19,7 @@ const BrewItem = createClass({
authors : [],
stubbed : true
},
updateListFilter : ()=>{},
reportError : ()=>{}
reportError : ()=>{}
};
},
@@ -43,10 +43,6 @@ const BrewItem = createClass({
});
},
updateFilter : function(type, term){
this.props.updateListFilter(type, term);
},
renderDeleteBrewLink : function(){
if(!this.props.brew.editId) return;
@@ -94,17 +90,11 @@ const BrewItem = createClass({
</a>;
},
renderStorageIcon : function(){
if(this.props.brew.googleId) {
return <span title={this.props.brew.webViewLink ? 'Your Google Drive Storage': 'Another User\'s Google Drive Storage'}>
<a href={this.props.brew.webViewLink} target='_blank'>
<img className='googleDriveIcon' src={googleDriveIcon} alt='googleDriveIcon' />
</a>
</span>;
}
renderGoogleDriveIcon : function(){
if(!this.props.brew.googleId) return;
return <span title='Homebrewery Storage'>
<img className='homebreweryIcon' src={homebreweryIcon} alt='homebreweryIcon' />
return <span>
<img className='googleDriveIcon' src={googleDriveIcon} alt='googleDriveIcon' />
</span>;
},
@@ -112,9 +102,6 @@ const BrewItem = createClass({
const brew = this.props.brew;
if(Array.isArray(brew.tags)) { // temporary fix until dud tags are cleaned
brew.tags = brew.tags?.filter((tag)=>tag); //remove tags that are empty strings
brew.tags.sort((a, b)=>{
return a.indexOf(':') - b.indexOf(':') != 0 ? a.indexOf(':') - b.indexOf(':') : a.toLowerCase().localeCompare(b.toLowerCase());
});
}
const dateFormatString = 'YYYY-MM-DD HH:mm:ss';
@@ -131,21 +118,17 @@ const BrewItem = createClass({
<div className='info'>
{brew.tags?.length ? <>
<div className='brewTags' title={`${brew.tags.length} tags:\n${brew.tags.join('\n')}`}>
<div className='brewTags' title={`Tags:\n${brew.tags.join('\n')}`}>
<i className='fas fa-tags'/>
{brew.tags.map((tag, idx)=>{
const matches = tag.match(/^(?:([^:]+):)?([^:]+)$/);
return <span key={idx} className={matches[1]} onClick={()=>{this.updateFilter(tag);}}>{matches[2]}</span>;
return <span key={idx} className={matches[1]}>{matches[2]}</span>;
})}
</div>
</> : <></>
}
<span title={`Authors:\n${brew.authors?.join('\n')}`}>
<i className='fas fa-user'/> {brew.authors?.map((author, index)=>(
<>
<a key={index} href={`/user/${author}`}>{author}</a>
{index < brew.authors.length - 1 && ', '}
</>))}
<i className='fas fa-user'/> {brew.authors?.join(', ')}
</span>
<br />
<span title={`Last viewed: ${moment(brew.lastViewed).local().format(dateFormatString)}`}>
@@ -161,7 +144,7 @@ const BrewItem = createClass({
Last updated: ${moment(brew.updatedAt).local().format(dateFormatString)}`}>
<i className='fas fa-sync-alt' /> {moment(brew.updatedAt).fromNow()}
</span>
{this.renderStorageIcon()}
{this.renderGoogleDriveIcon()}
</div>
<div className='links'>

View File

@@ -48,10 +48,6 @@
&>span{
margin-right : 12px;
line-height : 1.5em;
a {
color:inherit;
}
}
}
.brewTags span {
@@ -63,41 +59,6 @@
white-space: nowrap;
display: inline-block;
font-weight: bold;
border-color: currentColor;
cursor : pointer;
&:before {
font-family: 'Font Awesome 5 Free';
font-size: 12px;
margin-right: 3px;
}
&.type {
background-color: #0080003b;
color: #008000;
&:before{
content: '\f0ad';
}
}
&.group {
background-color: #5050503b;
color: #000000;
&:before{
content: '\f500';
}
}
&.meta {
background-color: #0000803b;
color: #000080;
&:before{
content: '\f05a';
}
}
&.system {
background-color: #8000003b;
color: #800000;
&:before{
content: '\f518';
}
}
}
&:hover{
.links{
@@ -119,12 +80,11 @@
text-align : center;
a{
.animate(opacity);
display : block;
margin : 8px 0px;
opacity : 0.6;
font-size : 1.3em;
color : white;
text-decoration : unset;
display : block;
margin : 8px 0px;
opacity : 0.6;
font-size : 1.3em;
color : white;
&:hover{
opacity : 1;
}
@@ -138,11 +98,4 @@
padding : 0px;
margin : -5px;
}
.homebreweryIcon {
mix-blend-mode : darken;
height : 24px;
position : relative;
top : 5px;
left : -5px;
}
}

View File

@@ -36,7 +36,6 @@ const ListPage = createClass({
return {
filterString : this.props.query?.filter || '',
filterTags : [],
sortType : this.props.query?.sort || null,
sortDir : this.props.query?.dir || null,
query : this.props.query,
@@ -83,14 +82,14 @@ const ListPage = createClass({
if(!brews || !brews.length) return <div className='noBrews'>No Brews.</div>;
return _.map(brews, (brew, idx)=>{
return <BrewItem brew={brew} key={idx} reportError={this.props.reportError} updateListFilter={ (tag)=>{ this.updateUrl(this.state.filterString, this.state.sortType, this.state.sortDir, tag); }}/>;
return <BrewItem brew={brew} key={idx} reportError={this.props.reportError}/>;
});
},
sortBrewOrder : function(brew){
if(!brew.title){brew.title = 'No Title';}
const mapping = {
'alpha' : _.deburr(brew.title.trim().toLowerCase()),
'alpha' : _.deburr(brew.title.toLowerCase()),
'created' : moment(brew.createdAt).format(),
'updated' : moment(brew.updatedAt).format(),
'views' : brew.views,
@@ -137,33 +136,13 @@ const ListPage = createClass({
return;
},
updateUrl : function(filterTerm, sortType, sortDir, filterTag=''){
updateUrl : function(filterTerm, sortType, sortDir){
const url = new URL(window.location.href);
const urlParams = new URLSearchParams(url.search);
urlParams.set('sort', sortType);
urlParams.set('dir', sortDir);
let filterTags = urlParams.getAll('tag');
if(filterTag != '') {
if(filterTags.findIndex((tag)=>{return tag.toLowerCase()==filterTag.toLowerCase();}) == -1){
filterTags.push(filterTag);
} else {
filterTags = filterTags.filter((tag)=>{ return tag.toLowerCase() != filterTag.toLowerCase(); });
}
}
urlParams.delete('tag');
// Add tags to URL in the order they were clicked
filterTags.forEach((tag)=>{ urlParams.append('tag', tag); });
// Sort tags before updating state
filterTags.sort((a, b)=>{
return a.indexOf(':') - b.indexOf(':') != 0 ? a.indexOf(':') - b.indexOf(':') : a.toLowerCase().localeCompare(b.toLowerCase());
});
this.setState({
filterTags
});
if(!filterTerm)
urlParams.delete('filter');
else
@@ -187,16 +166,6 @@ const ListPage = createClass({
</div>;
},
renderTagsOptions : function(){
if(this.state.filterTags?.length == 0) return;
return <div className='tags-container'>
{_.map(this.state.filterTags, (tag, idx)=>{
const matches = tag.match(/^(?:([^:]+):)?([^:]+)$/);
return <span key={idx} className={matches[1]} onClick={()=>{ this.updateUrl(this.state.filterString, this.state.sortType, this.state.sortDir, tag); }}>{matches[2]}</span>;
})}
</div>;
},
renderSortOptions : function(){
return <div className='sort-container'>
<h6>Sort by :</h6>
@@ -207,6 +176,9 @@ const ListPage = createClass({
{/* {this.renderSortOption('Latest', 'latest')} */}
{this.renderFilterOption()}
</div>;
},
@@ -214,28 +186,14 @@ const ListPage = createClass({
const testString = _.deburr(this.state.filterString).toLowerCase();
brews = _.filter(brews, (brew)=>{
// Filter by user entered text
const brewStrings = _.deburr([
brew.title,
brew.description,
brew.tags].join('\n')
.toLowerCase());
const filterTextTest = brewStrings.includes(testString);
// Filter by user selected tags
let filterTagTest = true;
if(this.state.filterTags.length > 0){
filterTagTest = Array.isArray(brew.tags) && this.state.filterTags?.every((tag)=>{
return brew.tags.findIndex((brewTag)=>{
return brewTag.toLowerCase() == tag.toLowerCase();
}) >= 0;
});
}
return filterTextTest && filterTagTest;
return brewStrings.includes(testString);
});
return _.orderBy(brews, (brew)=>{ return this.sortBrewOrder(brew); }, this.state.sortDir);
},
@@ -262,11 +220,9 @@ const ListPage = createClass({
render : function(){
return <div className='listPage sitePage'>
{/*<style>@layer V3_5ePHB, bundle;</style>*/}
<link href='/themes/V3/Blank/style.css' type='text/css' rel='stylesheet'/>
<link href='/themes/V3/5ePHB/style.css' type='text/css' rel='stylesheet'/>
<link href='/themes/V3/5ePHB/style.css' rel='stylesheet'/>
{this.props.navItems}
{this.renderSortOptions()}
{this.renderTagsOptions()}
<div className='content V3'>
<div className='page'>

View File

@@ -2,18 +2,17 @@
.noColumns(){
column-count : auto;
column-fill : auto;
column-gap : normal;
column-gap : auto;
column-width : auto;
-webkit-column-count : auto;
-moz-column-count : auto;
-webkit-column-width : auto;
-moz-column-width : auto;
-webkit-column-gap : normal;
-moz-column-gap : normal;
-webkit-column-gap : auto;
-moz-column-gap : auto;
height : auto;
min-height : 279.4mm;
margin : 20px auto;
contain : unset;
}
.listPage{
.content{
@@ -53,7 +52,7 @@
}
}
}
.sort-container {
.sort-container{
font-family : 'Open Sans', sans-serif;
position : sticky;
top : 0;
@@ -125,66 +124,4 @@
}
.tags-container {
height : 30px;
background-color : #555;
border-top : 1px solid #666;
border-bottom : 1px solid #666;
color : white;
display : flex;
justify-content : center;
align-items : center;
column-gap : 15px;
row-gap : 5px;
flex-wrap : wrap;
span {
font-family : 'Open Sans', sans-serif;
font-size : 11px;
font-weight : bold;
border : 1px solid;
border-radius : 3px;
padding : 3px;
cursor : pointer;
color: #dfdfdf;
&:before {
font-family: 'Font Awesome 5 Free';
font-size: 12px;
margin-right: 3px;
}
&:after {
content: '\f00d';
font-family: 'Font Awesome 5 Free';
font-size: 12px;
margin-left: 3px;
}
&.type {
background-color: #008000;
border-color: #00a000;
&:before{
content: '\f0ad';
}
}
&.group {
background-color: #505050;
border-color: #000000;
&:before{
content: '\f500';
}
}
&.meta {
background-color: #000080;
border-color: #0000a0;
&:before{
content: '\f05a';
}
}
&.system {
background-color: #800000;
border-color: #a00000;
&:before{
content: '\f518';
}
}
}
}
}

View File

@@ -16,23 +16,6 @@
margin : 5px 0px;
border : 2px solid black;
border-radius : 5px;
button {
background-color : transparent;
border : 1px solid black;
border-radius : 5px;
width : 125px;
color : black;
margin-right : 5px;
&.active {
background-color: #0007;
color: white;
&:before {
content: '\f00c';
font-family: 'FONT AWESOME 5 FREE';
margin-right: 5px;
}
}
}
}
h1, h2, h3, h4 {
width : 100%;

View File

@@ -11,7 +11,7 @@ const Navbar = require('../../navbar/navbar.jsx');
const NewBrew = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx');
const PrintNavItem = require('../../navbar/print.navitem.jsx');
const PrintLink = require('../../navbar/print.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const Account = require('../../navbar/account.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
@@ -20,12 +20,9 @@ const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
const LockNotification = require('./lockNotification/lockNotification.jsx');
const Markdown = require('naturalcrit/markdown.js');
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
const googleDriveIcon = require('../../googleDrive.svg');
@@ -53,14 +50,9 @@ const EditPage = createClass({
url : '',
autoSave : true,
autoSaveWarning : false,
unsavedTime : new Date(),
currentEditorPage : 0,
displayLockMessage : this.props.brew.lock || false,
themeBundle : {}
unsavedTime : new Date()
};
},
editor : React.createRef(null),
savedBrew : null,
componentDidMount : function(){
@@ -88,8 +80,6 @@ const EditPage = createClass({
htmlErrors : Markdown.validate(prevState.brew.text)
}));
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
document.addEventListener('keydown', this.handleControlKeys);
},
componentWillUnmount : function() {
@@ -101,8 +91,8 @@ const EditPage = createClass({
if(!(e.ctrlKey || e.metaKey)) return;
const S_KEY = 83;
const P_KEY = 80;
if(e.keyCode == S_KEY) this.trySave(true);
if(e.keyCode == P_KEY) printCurrentBrew();
if(e.keyCode == S_KEY) this.save();
if(e.keyCode == P_KEY) window.open(`/print/${this.processShareId()}?dialog=true`, '_blank').focus();
if(e.keyCode == P_KEY || e.keyCode == S_KEY){
e.stopPropagation();
e.preventDefault();
@@ -110,7 +100,7 @@ const EditPage = createClass({
},
handleSplitMove : function(){
this.editor.current.update();
this.refs.editor.update();
},
handleTextChange : function(text){
@@ -119,10 +109,9 @@ const EditPage = createClass({
if(htmlErrors.length) htmlErrors = Markdown.validate(text);
this.setState((prevState)=>({
brew : { ...prevState.brew, text: text },
isPending : true,
htmlErrors : htmlErrors,
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
brew : { ...prevState.brew, text: text },
isPending : true,
htmlErrors : htmlErrors
}), ()=>{if(this.state.autoSave) this.trySave();});
},
@@ -133,10 +122,7 @@ const EditPage = createClass({
}), ()=>{if(this.state.autoSave) this.trySave();});
},
handleMetaChange : function(metadata, field=undefined){
if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed
fetchThemeBundle(this, metadata.renderer, metadata.theme);
handleMetaChange : function(metadata){
this.setState((prevState)=>({
brew : {
...prevState.brew,
@@ -144,20 +130,20 @@ const EditPage = createClass({
},
isPending : true,
}), ()=>{if(this.state.autoSave) this.trySave();});
},
hasChanges : function(){
return !_.isEqual(this.state.brew, this.savedBrew);
},
trySave : function(immediate=false){
trySave : function(){
if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT);
if(this.hasChanges()){
this.debounceSave();
} else {
this.debounceSave.cancel();
}
if(immediate) this.debounceSave.flush();
},
handleGoogleClick : function(){
@@ -387,7 +373,7 @@ const EditPage = createClass({
post to reddit
</Nav.item>
</Nav.dropdown>
<PrintNavItem />
<PrintLink shareId={this.processShareId()} />
<RecentNavItem brew={this.state.brew} storageKey='edit' />
<Account />
</Nav.section>
@@ -401,29 +387,23 @@ const EditPage = createClass({
{this.renderNavbar()}
<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} ref='pane'>
<Editor
ref={this.editor}
ref='editor'
brew={this.state.brew}
onTextChange={this.handleTextChange}
onStyleChange={this.handleStyleChange}
onMetaChange={this.handleMetaChange}
reportError={this.errorReported}
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
snippetBundle={this.state.themeBundle.snippets}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors}
lang={this.state.brew.lang}
currentEditorPage={this.state.currentEditorPage}
allowPrint={true}
/>
</SplitPane>
</div>

View File

@@ -1,30 +0,0 @@
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

@@ -1,27 +0,0 @@
.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

@@ -1,25 +1,41 @@
require('./errorPage.less');
const React = require('react');
const UIPage = require('../basePages/uiPage/uiPage.jsx');
const Markdown = require('../../../../shared/naturalcrit/markdown.js');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
const UIPage = require('../basePages/uiPage/uiPage.jsx');
const Markdown = require('../../../../shared/naturalcrit/markdown.js');
const ErrorIndex = require('./errors/errorIndex.js');
const ErrorPage = ({ brew })=>{
// Retrieving the error text based on the brew's error code from ErrorIndex
const errorText = ErrorIndex({ brew })[brew.HBErrorCode.toString()] || '';
const ErrorPage = createClass({
displayName : 'ErrorPage',
return (
<UIPage brew={{ title: 'Crit Fail!' }}>
getDefaultProps : function() {
return {
ver : '0.0.0',
errorId : '',
text : '# Oops \n We could not find a brew with that id. **Sorry!**',
error : {}
};
},
render : function(){
const errorText = ErrorIndex(this.props)[this.props.brew.HBErrorCode.toString()] || '';
return <UIPage brew={{ title: 'Crit Fail!' }}>
<div className='dataGroup'>
<div className='errorTitle'>
<h1>{`Error ${brew?.status || '000'}`}</h1>
<h4>{brew?.text || 'No error text'}</h4>
<h1>{`Error ${this.props.brew.status || '000'}`}</h1>
<h4>{this.props.brew.text || 'No error text'}</h4>
</div>
<hr />
<div dangerouslySetInnerHTML={{ __html: Markdown.render(errorText) }} />
</div>
</UIPage>
);
};
</UIPage>;
}
});
module.exports = ErrorPage;

View File

@@ -22,18 +22,18 @@ const errorIndex = (props)=>{
## We can't find this brew in Google Drive!
This file was saved on Google Drive, but this link doesn't work anymore.
${props.brew.authors?.length > 0
? `Note that this brew belongs to the Homebrewery account **${props.brew.authors[0]}**,
${props.brew.account
? `which is
${ props.brew.authors?.length > 0
? `Note that this brew belongs to the Homebrewery account **${ props.brew.authors[0] }**,
${ props.brew.account
? `which is
${props.brew.authors[0] == props.brew.account
? `your account.`
: `not your account (you are currently signed in as **${props.brew.account}**).`
}`
: 'and you are not currently signed in to any account.'
}`
: ''
}
? `your account.`
: `not your account (you are currently signed in as **${props.brew.account}**).`
}`
: 'and you are not currently signed in to any account.'
}`
: ''
}
The Homebrewery cannot delete files from Google Drive on its own, so there
are three most likely possibilities:
:
@@ -73,13 +73,9 @@ const errorIndex = (props)=>{
**Properties** tab, and adding your username to the "invited authors" list. You can
then try to access this document again.
:
**Brew Title:** ${props.brew.brewTitle || 'Unable to show title'}
**Current Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${author})`;}).join(', ') || 'Unable to list authors'}
[Click here to be redirected to the brew's share page.](/share/${props.brew.shareId})`,
**Current Authors:** ${props.brew.authors?.map((author)=>{return `${author}`;}).join(', ') || 'Unable to list authors'}`,
// User is not signed in; must be a user on the Authors List
'04' : dedent`
@@ -88,14 +84,9 @@ const errorIndex = (props)=>{
You must be logged in to one of the accounts listed as an author of this brew.
User is not logged in. Please log in [here](${loginUrl}).
:
**Brew Title:** ${props.brew.brewTitle || 'Unable to show title'}
**Current Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${author})`;}).join(', ') || 'Unable to list authors'}
[Click here to be redirected to the brew's share page.](/share/${props.brew.shareId})`,
**Current Authors:** ${props.brew.authors?.map((author)=>{return `${author}`;}).join(', ') || 'Unable to list authors'}`,
// Brew load error
'05' : dedent`
@@ -104,8 +95,6 @@ const errorIndex = (props)=>{
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}`,
@@ -122,8 +111,6 @@ const errorIndex = (props)=>{
An error occurred while attempting to remove the Homebrewery document.
:
**Brew ID:** ${props.brew.brewId}`,
// Author delete error
@@ -132,34 +119,7 @@ const errorIndex = (props)=>{
An error occurred while attempting to remove the user from the Homebrewery document author list!
:
**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}`,
// Brew locked by Administrators error
'100' : dedent`
## This brew has been locked.
Only an author may request that this lock is removed.
:
**Brew ID:** ${props.brew.brewId}
**Brew Title:** ${props.brew.brewTitle}`,
};
};

View File

@@ -0,0 +1,12 @@
//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

@@ -13,7 +13,6 @@ const HelpNavItem = require('../../navbar/help.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const AccountNavItem = require('../../navbar/account.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const { fetchThemeBundle } = require('../../../../shared/helpers.js');
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
@@ -32,20 +31,11 @@ const HomePage = createClass({
},
getInitialState : function() {
return {
brew : this.props.brew,
welcomeText : this.props.brew.text,
error : undefined,
currentEditorPage : 0,
themeBundle : {}
brew : this.props.brew,
welcomeText : this.props.brew.text,
error : undefined
};
},
editor : React.createRef(null),
componentDidMount : function() {
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
},
handleSave : function(){
request.post('/api')
.send(this.state.brew)
@@ -59,12 +49,11 @@ const HomePage = createClass({
});
},
handleSplitMove : function(){
this.editor.current.update();
this.refs.editor.update();
},
handleTextChange : function(text){
this.setState((prevState)=>({
brew : { ...prevState.brew, text: text },
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
brew : { ...prevState.brew, text: text }
}));
},
renderNavbar : function(){
@@ -88,21 +77,15 @@ const HomePage = createClass({
{this.renderNavbar()}
<div className='content'>
<SplitPane onDragFinish={this.handleSplitMove}>
<SplitPane onDragFinish={this.handleSplitMove} ref='pane'>
<Editor
ref={this.editor}
ref='editor'
brew={this.state.brew}
onTextChange={this.handleTextChange}
renderer={this.state.brew.renderer}
showEditButtons={false}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
currentEditorPage={this.state.currentEditorPage}
themeBundle={this.state.themeBundle}
/>
<BrewRenderer text={this.state.brew.text} style={this.state.brew.style} renderer={this.state.brew.renderer}/>
</SplitPane>
</div>

View File

@@ -16,9 +16,9 @@ The Homebrewery makes the creation and sharing of authentic looking Fifth-Editio
**Try it!** Simply edit the text on the left and watch it *update live* on the right. Note that not every button is visible on this demo page. Click New {{fas,fa-plus-square}} in the navbar above to start brewing with all the features!
### Editing and Sharing
When you create a new homebrew document ("brew"), your document will be given a *edit link* and a *share link*.
When you create your own homebrew, you will be given a *edit url* and a *share url*.
The *edit link* is where you write your brew. If you edit a brew while logged in, you are added as one of the brew's authors, and no one else can edit that brew until you add them as a new author via the {{fa,fa-info-circle}} **Properties** tab. Brews without any author can still be edited by anyone with the *edit link*, so be careful about who you share it with if you prefer to work without an account.
Any changes you make while on the *edit url* will be automatically saved to the database within a few seconds. Anyone with the edit url will be able to make edits to your homebrew, so be careful about who you share it with.
Anyone with the *share url* will be able to access a read-only version of your homebrew.
@@ -48,63 +48,57 @@ If you want to save ink or have a monochrome printer, add the **PRINT → {{fas,
\column
## V3 vs Legacy
The Homebrewery has two renderers: Legacy and V3. The V3 renderer is recommended for all users because it is more powerful, more customizable, and continues to receive new feature updates while Legacy does not. However Legacy mode will remain available for older brews and veteran users.
At any time, any individual brew can be changed to your renderer of choice via the {{fa,fa-info-circle}} **Properties** tab on your brew. However, converting between Legacy and V3 may require heavily tweaking the document; while both renderers can use raw HTML, V3 prefers a streamlined curly bracket syntax that avoids the complex HTML structures required by Legacy.
## New in V3.0.0
We've implemented an extended Markdown-like syntax for block and span elements, plus a few other changes, eliminating the need for HTML tags like `div` and `span` in most cases. No raw HTML tags should be needed in a brew (*but can still be used if you insist*).
Much of the syntax and styling has changed in V3, so converting a Legacy brew to V3 (or vice-versa) will require tweaking your document. *However*, all brews made prior to the release of v3.0.0 will still render normally, and you may switch between the "Legacy" brew renderer and the newer "V3" renderer via the {{fa,fa-info-circle}} **Properties** button on your brew at any time.
Scroll down to the next page for a brief summary of the changes and new features available in V3!
Scroll down to the next page for a brief summary of the changes and features available in V3!
#### New Things All The Time!
Check out the latest updates in the full changelog [here](/changelog).
### Helping out
Like this tool? Head over to our [Patreon](https://www.patreon.com/Naturalcrit) to help us keep the servers running.
Like this tool? Want to buy me a beer? [Head here](https://www.patreon.com/Naturalcrit) to help me keep the servers running.
This tool will **always** be free, never have ads, and we will never offer any "premium" features or whatever.
This tool will **always** be free, never have ads, and I will never offer any "premium" features or whatever.
### Bugs, Issues, Suggestions?
- Check the [Frequently Asked Questions](/faq) page first for quick answers.
- Get help or the right look for your brew by posting on [r/Homebrewery](https://www.reddit.com/r/homebrewery/submit?selftext=true&title=%5BIssue%5D%20Describe%20Your%20Issue%20Here) or joining the [Discord Of Many Things](https://discord.gg/by3deKx).
- Report technical issues or provide feedback on the [GitHub Repo](https://github.com/naturalcrit/homebrewery/).
Take a quick look at our [Frequently Asked Questions page](/faq) to see if your question has a handy answer.
Need help getting started or just the right look for your brew? Head to [r/Homebrewery](https://www.reddit.com/r/homebrewery/submit?selftext=true&title=%5BIssue%5D%20Describe%20Your%20Issue%20Here) and let us know!
Have an idea to make The Homebrewery better? Or did you find something that wasn't quite right? Check out the [GitHub Repo](https://github.com/naturalcrit/homebrewery/) to report technical issues.
### Legal Junk
The Homebrewery is licensed using the [MIT License](https://github.com/naturalcrit/homebrewery/blob/master/license). Which means you are free to use The Homebrewery codebase any way that you want, except for claiming that you made it yourself.
If you wish to sell or in some way gain profit for what's created on this site, it's your responsibility to ensure you have the proper licenses/rights for any images or resources used.
#### Crediting Us
If you'd like to credit us in your brew, we'd be flattered! Just reference that you made it with The Homebrewery.
#### Crediting Me
If you'd like to credit me in your brew, I'd be flattered! Just reference that you made it with The Homebrewery.
### More Homebrew Resources
[![Discord](/assets/discordOfManyThings.svg){width:50px,float:right,padding-left:10px}](https://discord.gg/by3deKx)
If you are looking for more 5e Homebrew resources check out [r/UnearthedArcana](https://www.reddit.com/r/UnearthedArcana/) and their list of useful resources [here](https://www.reddit.com/r/UnearthedArcana/wiki/resources). The [Discord Of Many Things](https://discord.gg/by3deKx) is another great resource to connect with fellow homebrewers for help and feedback.
<a href='https://discord.gg/by3deKx' target='_blank'><img src='/assets/discordOfManyThings.svg' alt='Discord of Many Things Logo' title='Discord of Many Things Logo' style='width:50px; float: right; padding-left: 10px;'/></a>
If you are looking for more 5e Homebrew resources check out [r/UnearthedArcana](https://www.reddit.com/r/UnearthedArcana/) and their list of useful resources [here](https://www.reddit.com/r/UnearthedArcana/wiki/resources). The <a href='https://discord.gg/by3deKx' target='_blank' title='Discord of Many Things'>Discord of Many Things</a> is another great resource to connect with fellow homebrewers for help and feedback.
{{position:absolute;top:20px;right:20px;width:auto
[![Discord](/assets/discord.png){height:30px}](https://discord.gg/by3deKx)
[![Github](/assets/github.png){height:30px}](https://github.com/naturalcrit/homebrewery)
[![Patreon](/assets/patreon.png){height:30px}](https://patreon.com/NaturalCrit)
[![Reddit](/assets/reddit.png){height:30px}](https://www.reddit.com/r/homebrewery/)
<a href='https://discord.gg/by3deKx' target='_blank' title='Discord of Many Things' style='color: black;'><img src='/assets/discord.png' style='height:30px'/></a>
<a href='https://github.com/naturalcrit/homebrewery' target='_blank' title='Github' style='color: black; padding-left: 5px;'><img src='/assets/github.png' style='height:30px'/></a>
<a href='https://patreon.com/NaturalCrit' target='_blank' title='Patreon' style='color: black; padding-left: 5px;'><img src='/assets/patreon.png' style='height:30px'/></a>
<a href='https://www.reddit.com/r/homebrewery/' target='_blank' title='Reddit' style='color: black; padding-left: 5px;'><img src='/assets/reddit.png' style='height:30px'/></a>
}}
\page
## Markdown+
The Homebrewery aims to make homebrewing as simple as possible, providing a live editor with Markdown syntax that is more human-readable and faster to write with than raw HTML.
From version 3.0.0, with a goal of adding maximum flexibility without users resorting to complex HTML to accomplish simple tasks, Homebrewery provides an extended verision of Markdown with additional syntax.
In version 3.0.0, with a goal of adding maximum flexibility without users resorting to complex HTML to accomplish simple tasks, Homebrewery provides an extended verision of Markdown with additional syntax.
**You can enable V3 via the {{fa,fa-info-circle}} Properties button!**
### Curly Brackets
Standard Markdown lacks several equivalences to HTML. Hence, we have introduced `{{ }}` as a replacement for `<span></span>` and `<div></div>` for a cleaner custom formatting. Inline spans and block elements can be created and given ID's and Classes, as well as CSS properties, each of which are comma separated with no spaces. Use double quotes if a value requires spaces. Spans and Blocks start the same:
The biggest change in V3 is the replacement of `<span></span>` and `<div></div>` with `{{ }}` for a cleaner custom formatting. Inline spans and block elements can be created and given ID's and Classes, as well as css properties, each of which are comma separated with no spaces. Use double quotes if a value requires spaces. Spans and Blocks start the same:
#### Span
My favorite author is {{pen,#author,color:orange,font-family:"trebuchet ms" Brandon Sanderson}}. The orange text has a class of `pen`, an id of `author`, is colored orange, and given a new font. The first space outside of quotes marks the beginning of the content.
@@ -132,18 +126,16 @@ A blank line can be achieved with a run of one or more `:` alone on a line. More
::
Much nicer than `<br><br><br><br><br>`
### Definition Lists
**Example** :: V3 uses HTML *definition lists* to create "lists" with hanging indents.
### Column Breaks
Column and page breaks with `\column` and `\page`.
\column
### Tables
Tables now allow column & row spanning between cells. This is included in some updated snippets, but a simplified example is given below.
@@ -171,13 +163,13 @@ Using *Curly Injection* you can assign an id, classes, or inline CSS properties
![alt-text](https://s-media-cache-ak0.pinimg.com/736x/4a/81/79/4a8179462cfdf39054a418efd4cb743e.jpg) {width:100px,border:"2px solid",border-radius:10px}
\* *When using Imgur-hosted images, use the "direct link", which can be found when you click into your image in the Imgur interface.*
\* *When using Imgur-hosted images, use the "direct link", which can be found when you click into your image in the Imgur interace.*
## Snippets
Homebrewery comes with a series of *code snippets* found at the top of the editor pane that make it easy to create brews as quickly as possible. Just set your cursor where you want the code to appear in the editor pane, choose a snippet, and make the adjustments you need.
## Style Editor Panel
{{fa,fa-paint-brush}} Usually overlooked or unused by some users, the **Style Editor** tab is located on the right side of the Snippet bar. This editor accepts CSS for styling without requiring `<style>` tags-- anything that would have gone inside style tags before can now be placed here, and snippets that insert CSS styles are now located on that tab.
{{fa,fa-paint-brush}} Technically released prior to v3 but still new to many users, check out the new **Style Editor** located on the right side of the Snippet bar. This editor accepts CSS for styling without requiring `<style>` tags-- anything that would have gone inside style tags before can now be placed here, and snippets that insert CSS styles are now located on that tab.
{{pageNumber 2}}
{{footnote PART 2 | BORING STUFF}}

View File

@@ -2,12 +2,12 @@
require('./newPage.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const request = require('../../utils/request-middleware.js');
const Markdown = require('naturalcrit/markdown.js');
const Nav = require('naturalcrit/nav/nav.jsx');
const PrintNavItem = require('../../navbar/print.navitem.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const AccountNavItem = require('../../navbar/account.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
@@ -19,12 +19,10 @@ const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
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 METAKEY = 'homebrewery-new-meta';
let SAVEKEY;
const METAKEY = 'homebrewery-new-meta';
const NewPage = createClass({
@@ -39,18 +37,14 @@ const NewPage = createClass({
const brew = this.props.brew;
return {
brew : brew,
isSaving : false,
saveGoogle : (global.account && global.account.googleId ? true : false),
error : null,
htmlErrors : Markdown.validate(brew.text),
currentEditorPage : 0,
themeBundle : {}
brew : brew,
isSaving : false,
saveGoogle : (global.account && global.account.googleId ? true : false),
error : null,
htmlErrors : Markdown.validate(brew.text)
};
},
editor : React.createRef(null),
componentDidMount : function() {
document.addEventListener('keydown', this.handleControlKeys);
@@ -68,18 +62,12 @@ const NewPage = createClass({
brew.renderer = metaStorage?.renderer ?? brew.renderer;
brew.theme = metaStorage?.theme ?? brew.theme;
brew.lang = metaStorage?.lang ?? brew.lang;
this.setState({
brew : brew
});
}
SAVEKEY = `HOMEBREWERY-DEFAULT-SAVE-LOCATION-${global.account?.username || ''}`;
const saveStorage = localStorage.getItem(SAVEKEY) || 'HOMEBREWERY';
this.setState({
brew : brew,
saveGoogle : (saveStorage == 'GOOGLE-DRIVE' && this.state.saveGoogle)
});
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
localStorage.setItem(BREWKEY, brew.text);
if(brew.style)
localStorage.setItem(STYLEKEY, brew.style);
@@ -94,7 +82,7 @@ const NewPage = createClass({
const S_KEY = 83;
const P_KEY = 80;
if(e.keyCode == S_KEY) this.save();
if(e.keyCode == P_KEY) printCurrentBrew();
if(e.keyCode == P_KEY) this.print();
if(e.keyCode == P_KEY || e.keyCode == S_KEY){
e.stopPropagation();
e.preventDefault();
@@ -102,7 +90,7 @@ const NewPage = createClass({
},
handleSplitMove : function(){
this.editor.current.update();
this.refs.editor.update();
},
handleTextChange : function(text){
@@ -111,9 +99,8 @@ const NewPage = createClass({
if(htmlErrors.length) htmlErrors = Markdown.validate(text);
this.setState((prevState)=>({
brew : { ...prevState.brew, text: text },
htmlErrors : htmlErrors,
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
brew : { ...prevState.brew, text: text },
htmlErrors : htmlErrors
}));
localStorage.setItem(BREWKEY, text);
},
@@ -125,10 +112,7 @@ const NewPage = createClass({
localStorage.setItem(STYLEKEY, style);
},
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);
handleMetaChange : function(metadata){
this.setState((prevState)=>({
brew : { ...prevState.brew, ...metadata },
}), ()=>{
@@ -148,6 +132,8 @@ const NewPage = createClass({
isSaving : true
});
console.log('saving new brew');
let brew = this.state.brew;
// Split out CSS to Style if CSS codefence exists
if(brew.text.startsWith('```css') && brew.text.indexOf('```\n\n') > 0) {
@@ -157,10 +143,12 @@ const NewPage = createClass({
}
brew.pageCount=((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
const res = await request
.post(`/api${this.state.saveGoogle ? '?saveToGoogle=true' : ''}`)
.send(brew)
.catch((err)=>{
console.log(err);
this.setState({ isSaving: false, error: err });
});
if(!res) return;
@@ -184,6 +172,16 @@ 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(){
return <Navbar>
@@ -196,7 +194,7 @@ const NewPage = createClass({
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
this.renderSaveButton()
}
<PrintNavItem />
{this.renderLocalPrintButton()}
<HelpNavItem />
<RecentNavItem />
<AccountNavItem />
@@ -208,27 +206,16 @@ const NewPage = createClass({
return <div className='newPage sitePage'>
{this.renderNavbar()}
<div className='content'>
<SplitPane onDragFinish={this.handleSplitMove}>
<SplitPane onDragFinish={this.handleSplitMove} ref='pane'>
<Editor
ref={this.editor}
ref='editor'
brew={this.state.brew}
onTextChange={this.handleTextChange}
onStyleChange={this.handleStyleChange}
onMetaChange={this.handleMetaChange}
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors}
lang={this.state.brew.lang}
currentEditorPage={this.state.currentEditorPage}
allowPrint={true}
/>
<BrewRenderer text={this.state.brew.text} style={this.state.brew.style} renderer={this.state.brew.renderer} theme={this.state.brew.theme} lang={this.state.brew.lang} errors={this.state.htmlErrors}/>
</SplitPane>
</div>
</div>;

View File

@@ -0,0 +1,110 @@
require('./printPage.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
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'
}
};
},
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'
}
};
},
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'
}
};
});
}
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`} rel='stylesheet'/>
{baseThemePath &&
<link href={`/themes/${rendererPath}/${baseThemePath}/style.css`} rel='stylesheet'/>
}
<link href={`/themes/${rendererPath}/${themePath}/style.css`} rel='stylesheet'/>
{/* Apply CSS from Style tab */}
{this.renderStyle()}
<div className='pages' ref='pages'>
{this.renderPages()}
</div>
</div>;
}
});
module.exports = PrintPage;

View File

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

View File

@@ -6,32 +6,25 @@ const { Meta } = require('vitreum/headtags');
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const MetadataNav = require('../../navbar/metadata.navitem.jsx');
const PrintNavItem = require('../../navbar/print.navitem.jsx');
const PrintLink = require('../../navbar/print.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const Account = require('../../navbar/account.navitem.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
const SharePage = createClass({
displayName : 'SharePage',
getDefaultProps : function() {
return {
brew : DEFAULT_BREW_LOAD,
};
},
getInitialState : function() {
return {
themeBundle : {}
brew : DEFAULT_BREW_LOAD
};
},
componentDidMount : function() {
document.addEventListener('keydown', this.handleControlKeys);
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
},
componentWillUnmount : function() {
@@ -42,7 +35,7 @@ const SharePage = createClass({
if(!(e.ctrlKey || e.metaKey)) return;
const P_KEY = 80;
if(e.keyCode == P_KEY){
if(e.keyCode == P_KEY) printCurrentBrew();
window.open(`/print/${this.processShareId()}?dialog=true`, '_blank').focus();
e.stopPropagation();
e.preventDefault();
}
@@ -54,19 +47,6 @@ const SharePage = createClass({
this.props.brew.shareId;
},
renderEditLink : function(){
if(!this.props.brew.editId) return;
let editLink = this.props.brew.editId;
if(this.props.brew.googleId && !this.props.brew.stubbed) {
editLink = this.props.brew.googleId + editLink;
}
return <Nav.item color='orange' icon='fas fa-pencil-alt' href={`/edit/${editLink}`}>
edit
</Nav.item>;
},
render : function(){
return <div className='sharePage sitePage'>
<Meta name='robots' content='noindex, nofollow' />
@@ -79,19 +59,18 @@ const SharePage = createClass({
<Nav.section>
{this.props.brew.shareId && <>
<PrintNavItem/>
<PrintLink shareId={this.processShareId()} />
<Nav.dropdown>
<Nav.item color='red' icon='fas fa-code'>
source
</Nav.item>
<Nav.item color='blue' icon='fas fa-eye' href={`/source/${this.processShareId()}`}>
<Nav.item color='blue' href={`/source/${this.processShareId()}`}>
view
</Nav.item>
{this.renderEditLink()}
<Nav.item color='blue' icon='fas fa-download' href={`/download/${this.processShareId()}`}>
<Nav.item color='blue' href={`/download/${this.processShareId()}`}>
download
</Nav.item>
<Nav.item color='blue' icon='fas fa-clone' href={`/new/${this.processShareId()}`}>
<Nav.item color='blue' href={`/new/${this.processShareId()}`}>
clone to new
</Nav.item>
</Nav.dropdown>
@@ -102,14 +81,7 @@ const SharePage = createClass({
</Navbar>
<div className='content'>
<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}
/>
<BrewRenderer text={this.props.brew.text} style={this.props.brew.style} renderer={this.props.brew.renderer} theme={this.props.brew.theme} />
</div>
</div>;
}

View File

@@ -1,6 +1,7 @@
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
const ListPage = require('../basePages/listPage/listPage.jsx');

View File

@@ -11,10 +11,9 @@ const template = async function(name, title='', props = {}){
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, height=device-height, interactive-widget=resizes-visual" />
<link href="//use.fontawesome.com/releases/v6.5.1/css/all.css" rel="stylesheet" type="text/css" />
<link href="//use.fontawesome.com/releases/v5.15.1/css/all.css" rel="stylesheet" />
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" />
<link href=${`/${name}/bundle.css`} type="text/css" rel='stylesheet' />
<link href=${`/${name}/bundle.css`} rel='stylesheet' />
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon" />
${ogMetaTags}
<meta name="twitter:card" content="summary">

View File

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

29
faq.md
View File

@@ -62,13 +62,16 @@ pre {
```
# FAQ
{{wide Updated Apr. 15, 2023}}
{{wide Updated Oct. 11, 2021}}
### The site is down for me! Anyone else?
You can check the site status here: [Everyone or Just Me](https://downforeveryoneorjustme.com/homebrewery.naturalcrit.com)
### How do I log out?
Go to https://homebrewery.naturalcrit.com/login, and hit the "*logout*" link.
### Why am I getting an error when trying to save, and my account is linked to Google?
@@ -102,7 +105,7 @@ The best way to avoid this is to leave space at the end of a column equal to one
### Why do I need to manually create a new page? Why doesn't text flow between pages?
A Homebrewery document is at its core an HTML & CSS document, and currently limited by the specs of those technologies. It is currently not possible to flow content from inside one box ("page") to the inside of another box. It seems likely that someday CSS will add this capability, and if/when that happens, Homebrewery will adopt it as soon as possible.
A Homebrewery document is at it's core an HTML & CSS document, and currently limited by the specs of those technologies. It is currently not possible to flow content from inside one box ("page") to the inside of another box. It seems likely that someday CSS will add this capability, and if/when that happens, Homebrewery will adopt it as soon as possible.
### Where do I get images?
The Homebrewery does not provide images for use besides some page elements and example images for snippets. You will need to find your own images for use and be sure you are following the appropriate license requirements.
@@ -117,6 +120,26 @@ The fonts used were originally created for use with the English language, though
### Whenever I click on the "Get PDF" button, instead of getting a download, it opens Print Preview in another tab.
Yes, this is by design. In the print preview, select "Save as PDF" as the Destination, and then click "Save". There will be a normal download dialog where you can save your brew as a PDF.
### The preview window is suddenly gone, I can only see the editor side of the Homebrewery (or the other way around).
1. Press `CTRL`+`SHIFT`+`i` (or right-click and select "Inspect") while in the Homebrewery.
2. Expand...
```
- `body`
- `main`
- `div class="homebrew"`
- `div class="editPage page"`
- `div class="content"`
- `div class="splitPane"`
```
There you will find 3 divs: `div class="pane" [...]`, `div class="divider" [...]`, and `div class="pane" [...]`.
The `class="pane"` looks similar to this: `div class="pane" data-reactid="36" style="flex: 0 0 auto; width: 925px;"`.
Change whatever stands behind width: to something smaller than your display width.
### I have white borders on the bottom/sides of the print preview.
The Homebrewery paper size and your print paper size do not match.
@@ -126,4 +149,4 @@ The Homebrewery defaults to creating US Letter page sizes. If you are printing
### Typing `#### Adhesion` in the text editor doesn't show the header at all in the completed page?
Your ad-blocking software is mistakenly assuming your text to be an ad. Whitelist homebrewery.naturalcrit.com in your ad-blocking software.
Your ad-blocking software is mistakenly assuming your text to be an ad. Whitelist homebrewery.naturalcrit.com in your ad-blocking software.

View File

@@ -13,7 +13,7 @@ npm install
npm audit fix
npm run postinstall
cp install/freebsd/rc.d/homebrewery /usr/local/etc/rc.d/
cp freebsd/rc.d/homebrewery /usr/local/etc/rc.d/
chmod +x /usr/local/etc/rc.d/homebrewery
sysrc homebrewery_enable=YES

7352
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,9 @@
{
"name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.14.0",
"version": "3.9.1",
"engines": {
"npm": "^10.2.x",
"node": "^20.8.x"
"node": ">=18.16.x"
},
"repository": {
"type": "git",
@@ -22,18 +21,14 @@
"circleci": "npm test && eslint **/*.{js,jsx} --max-warnings=0",
"verify": "npm run lint && npm test",
"test": "jest --runInBand",
"test:api-unit": "jest \"server/.*.spec.js\" --verbose",
"test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose",
"test:api-unit": "jest server/*.spec.js --verbose",
"test:coverage": "jest --coverage --silent --runInBand",
"test:dev": "jest --verbose --watch",
"test:basic": "jest tests/markdown/basic.test.js --verbose",
"test:variables": "jest tests/markdown/variables.test.js --verbose",
"test:mustache-syntax": "jest \".*(mustache-syntax).*\" --verbose --noStackTrace",
"test:mustache-syntax:inline": "jest \".*(mustache-syntax).*\" -t '^Inline:.*' --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:definition-lists": "jest tests/markdown/definition-lists.test.js --verbose --noStackTrace",
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
"test:mustache-syntax": "jest '.*(mustache-syntax).*' --verbose --noStackTrace",
"test:mustache-syntax:inline": "jest '.*(mustache-syntax).*' -t '^Inline:.*' --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:route": "jest tests/routes/static-pages.test.js --verbose",
"phb": "node scripts/phb.js",
"prod": "set NODE_ENV=production && npm run build",
@@ -57,15 +52,15 @@
],
"coverageThreshold": {
"global": {
"statements": 50,
"branches": 40,
"functions": 40,
"lines": 50
"statements": 25,
"branches": 10,
"functions": 22,
"lines": 25
},
"server/homebrew.api.js": {
"statements": 70,
"statements": 65,
"branches": 50,
"functions": 65,
"functions": 60,
"lines": 70
}
},
@@ -83,56 +78,54 @@
]
},
"dependencies": {
"@babel/core": "^7.24.7",
"@babel/plugin-transform-runtime": "^7.24.7",
"@babel/preset-env": "^7.24.7",
"@babel/preset-react": "^7.24.7",
"@googleapis/drive": "^8.11.0",
"@babel/core": "^7.22.8",
"@babel/plugin-transform-runtime": "^7.22.7",
"@babel/preset-env": "^7.22.7",
"@babel/preset-react": "^7.22.5",
"@googleapis/drive": "^5.1.0",
"body-parser": "^1.20.2",
"classnames": "^2.5.1",
"classnames": "^2.3.2",
"codemirror": "^5.65.6",
"cookie-parser": "^1.4.6",
"create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3",
"dompurify": "^3.1.5",
"expr-eval": "^2.0.2",
"express": "^4.19.2",
"express": "^4.18.2",
"express-async-handler": "^1.2.0",
"express-static-gzip": "2.1.7",
"fs-extra": "11.2.0",
"fs-extra": "11.1.1",
"js-yaml": "^4.1.0",
"jwt-simple": "^0.5.6",
"less": "^3.13.1",
"lodash": "^4.17.21",
"marked": "11.2.0",
"marked-emoji": "^1.4.1",
"marked-extended-tables": "^1.0.8",
"marked-gfm-heading-id": "^3.2.0",
"marked-smartypants-lite": "^1.0.2",
"marked": "5.1.1",
"marked-extended-tables": "^1.0.6",
"marked-gfm-heading-id": "^3.0.4",
"marked-smartypants-lite": "^1.0.0",
"markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1",
"mongoose": "^8.4.5",
"moment": "^2.29.4",
"mongoose": "^7.3.2",
"nanoid": "3.3.4",
"nconf": "^0.12.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"nconf": "^0.12.0",
"npm": "^9.8.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-frame-component": "^4.1.3",
"react-router-dom": "6.24.1",
"react-router-dom": "6.14.1",
"sanitize-filename": "1.6.3",
"superagent": "^9.0.2",
"superagent": "^6.1.0",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
},
"devDependencies": {
"eslint": "^8.57.0",
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-react": "^7.34.3",
"jest": "^29.7.0",
"eslint": "^8.44.0",
"eslint-plugin-jest": "^27.2.2",
"eslint-plugin-react": "^7.32.2",
"jest": "^29.6.1",
"jest-expect-message": "^1.1.3",
"postcss-less": "^6.0.0",
"stylelint": "^15.11.0",
"stylelint-config-recess-order": "^4.6.0",
"stylelint": "^15.10.1",
"stylelint-config-recess-order": "^4.3.0",
"stylelint-config-recommended": "^13.0.0",
"stylelint-stylistic": "^0.4.3",
"supertest": "^7.0.0"
"supertest": "^6.3.3"
}
}

View File

@@ -99,27 +99,6 @@ fs.emptyDirSync('./build');
await fs.copy('./themes/assets', './build/assets');
await fs.copy('./client/icons', './build/icons');
//v==---------------------------MOVE CM EDITOR THEMES -----------------------------==v//
const editorThemesBuildDir = './build/homebrew/cm-themes';
await fs.copy('./node_modules/codemirror/theme', editorThemesBuildDir);
await fs.copy('./themes/codeMirror/customThemes', editorThemesBuildDir);
editorThemeFiles = fs.readdirSync(editorThemesBuildDir);
const editorThemeFile = './themes/codeMirror/editorThemes.json';
if(fs.existsSync(editorThemeFile)) fs.rmSync(editorThemeFile);
const stream = fs.createWriteStream(editorThemeFile, { flags: 'a' });
stream.write('[\n"default"');
for (themeFile of editorThemeFiles) {
stream.write(`,\n"${themeFile.slice(0, -4)}"`);
}
stream.write('\n]\n');
stream.end();
await fs.copy('./themes/codeMirror', './build/homebrew/codeMirror');
//v==----------------------------- BUNDLE PACKAGES --------------------------------==v//
const bundles = await pack('./client/homebrew/homebrew.jsx', {
@@ -154,14 +133,14 @@ fs.emptyDirSync('./build');
// build(bundles);
//
//In development, set up LiveReload (refreshes browser), and Nodemon (restarts server)
if(isDev){
livereload('./build'); // Install the Chrome extension LiveReload to automatically refresh the browser
watchFile('./server.js', { // Restart server when change detected to this file or any nested directory from here
ignore : ['./build', './client', './themes'], // Ignore folders that are not running server code / avoids unneeded restarts
ext : 'js json' // Extensions to watch (only .js/.json by default)
//watch : ['./server', './themes'], // Watch additional folders if needed
});
}
})().catch(console.error);
})().catch(console.error);
//In development, set up LiveReload (refreshes browser), and Nodemon (restarts server)
if(isDev){
livereload('./build'); // Install the Chrome extension LiveReload to automatically refresh the browser
watchFile('./server.js', { // Restart server when change detected to this file or any nested directory from here
ignore : ['./build', './client', './themes'], // Ignore folders that are not running server code / avoids unneeded restarts
ext : 'js json' // Extensions to watch (only .js/.json by default)
//watch : ['./server', './themes'], // Watch additional folders if needed
});
}

View File

@@ -25,8 +25,8 @@
"codemirror/addon/edit/closetag.js",
"codemirror/addon/edit/trailingspace.js",
"codemirror/addon/selection/active-line.js",
"codemirror/addon/hint/show-hint.js",
"moment",
"superagent"
"superagent",
"marked"
]
}

View File

@@ -7,14 +7,6 @@ DB.connect(config).then(()=>{
// before launching server
const PORT = process.env.PORT || config.get('web_port') || 8000;
server.app.listen(PORT, ()=>{
const reset = '\x1b[0m'; // Reset to default style
const bright = '\x1b[1m'; // Bright (bold) style
const cyan = '\x1b[36m'; // Cyan color
const underline = '\x1b[4m'; // Underlined style
console.log(`\n\tserver started at: ${new Date().toLocaleString()}`);
console.log(`\tserver on port: ${PORT}`);
console.log(`\t${bright + cyan}Open in browser: ${reset}${underline + bright + cyan}http://localhost:${PORT}${reset}\n\n`);
console.log(`server on port: ${PORT}`);
});
});

View File

@@ -26,124 +26,85 @@ const mw = {
}
};
const junkBrewPipeline = [
{ $match : {
updatedAt : { $lt: Moment().subtract(30, 'days').toDate() },
lastViewed : { $lt: Moment().subtract(30, 'days').toDate() }
} },
{ $project: { textBinSize: { $binarySize: '$textBin' } } },
{ $match: { textBinSize: { $lt: 140 } } },
{ $limit: 100 }
];
/* Search for brews that are older than 3 days and that are shorter than a tweet */
const junkBrewQuery = HomebrewModel.find({
'$where' : 'this.text.length < 140',
createdAt : {
$lt : Moment().subtract(30, 'days').toDate()
}
}).limit(100).maxTime(60000);
/* Search for brews that aren't compressed (missing the compressed text field) */
const uncompressedBrewQuery = HomebrewModel.find({
'text' : { '$exists': true }
}).lean().limit(10000).select('_id');
// Search for up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
router.get('/admin/cleanup', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 })
.then((objs)=>res.json({ count: objs.length }))
.catch((error)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
junkBrewQuery.exec((err, objs)=>{
if(err) return res.status(500).send(err);
return res.json({ count: objs.length });
});
});
// Delete up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
/* Removes all empty brews that are older than 3 days and that are shorter than a tweet */
router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 })
.then((docs)=>{
const ids = docs.map((doc)=>doc._id);
return HomebrewModel.deleteMany({ _id: { $in: ids } });
}).then((result)=>{
res.json({ count: result.deletedCount });
}).catch((error)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
junkBrewQuery.remove().exec((err, objs)=>{
if(err) return res.status(500).send(err);
return res.json({ count: objs.length });
});
});
/* Searches for matching edit or share id, also attempts to partial match */
router.get('/admin/lookup/:id', mw.adminOnly, async (req, res, next)=>{
HomebrewModel.findOne({
$or : [
{ editId: { $regex: req.params.id, $options: 'i' } },
{ shareId: { $regex: req.params.id, $options: 'i' } },
]
}).exec()
.then((brew)=>{
if(!brew) // No document found
return res.status(404).json({ error: 'Document not found' });
else
return res.json(brew);
})
.catch((err)=>{
console.error(err);
return res.status(500).json({ error: 'Internal Server Error' });
router.get('/admin/lookup/:id', mw.adminOnly, (req, res, next)=>{
HomebrewModel.findOne({ $or : [
{ editId: { '$regex': req.params.id, '$options': 'i' } },
{ shareId: { '$regex': req.params.id, '$options': 'i' } },
] }).exec((err, brew)=>{
return res.json(brew);
});
});
/* Find 50 brews that aren't compressed yet */
router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
const query = uncompressedBrewQuery.clone();
query.exec()
.then((objs)=>{
const ids = objs.map((obj)=>obj._id);
res.json({ count: ids.length, ids });
})
.catch((err)=>{
console.error(err);
res.status(500).send(err.message || 'Internal Server Error');
});
uncompressedBrewQuery.exec((err, objs)=>{
if(err) return res.status(500).send(err);
objs = objs.map((obj)=>{return obj._id;});
return res.json({ count: objs.length, ids: objs });
});
});
/* Compresses the "text" field of a brew to binary */
router.put('/admin/compress/:id', (req, res)=>{
HomebrewModel.findOne({ _id: req.params.id })
HomebrewModel.get({ _id: req.params.id })
.then((brew)=>{
if(!brew)
return res.status(404).send('Brew not found');
brew.textBin = zlib.deflateRawSync(brew.text); // Compress brew text to binary before saving
brew.text = undefined; // Delete the non-binary text field since it's not needed anymore
if(brew.text) {
brew.textBin = brew.textBin || zlib.deflateRawSync(brew.text); //Don't overwrite textBin if exists
brew.text = undefined;
}
return brew.save();
brew.save((err, obj)=>{
if(err) throw err;
return res.status(200).send(obj);
});
})
.then((obj)=>res.status(200).send(obj))
.catch((err)=>{
console.error(err);
res.status(500).send('Error while saving');
console.log(err);
return res.status(500).send('Error while saving');
});
});
router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
try {
const totalBrewsCount = await HomebrewModel.countDocuments({});
const publishedBrewsCount = await HomebrewModel.countDocuments({ published: true });
router.get('/admin/stats', mw.adminOnly, (req, res)=>{
HomebrewModel.count({}, (err, count)=>{
return res.json({
totalBrews : totalBrewsCount,
totalPublishedBrews : publishedBrewsCount
totalBrews : count
});
} catch (error) {
console.error(error);
return res.status(500).json({ error: 'Internal Server Error' });
}
});
});
router.get('/admin', mw.adminOnly, (req, res)=>{
templateFn('admin', {
url : req.originalUrl
})
.then((page)=>res.send(page))
.catch((err)=>res.sendStatus(500));
.then((page)=>res.send(page))
.catch((err)=>res.sendStatus(500));
});
module.exports = router;

View File

@@ -9,7 +9,7 @@ const yaml = require('js-yaml');
const app = express();
const config = require('./config.js');
const { homebrewApi, getBrew, getUsersBrewThemes } = require('./homebrew.api.js');
const { homebrewApi, getBrew } = require('./homebrew.api.js');
const GoogleActions = require('./googleActions.js');
const serveCompressedStaticAssets = require('./static-assets.mv.js');
const sanitizeFilename = require('sanitize-filename');
@@ -17,13 +17,26 @@ const asyncHandler = require('express-async-handler');
const { DEFAULT_BREW } = require('./brewDefaults.js');
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
const splitTextStyleAndMetadata = (brew)=>{
brew.text = brew.text.replaceAll('\r\n', '\n');
if(brew.text.startsWith('```metadata')) {
const index = brew.text.indexOf('```\n\n');
const metadataSection = brew.text.slice(12, index - 1);
const metadata = yaml.load(metadataSection);
Object.assign(brew, _.pick(metadata, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang']));
brew.text = brew.text.slice(index + 5);
}
if(brew.text.startsWith('```css')) {
const index = brew.text.indexOf('```\n\n');
brew.style = brew.text.slice(7, index - 1);
brew.text = brew.text.slice(index + 5);
}
};
const sanitizeBrew = (brew, accessType)=>{
brew._id = undefined;
brew.__v = undefined;
if(accessType !== 'edit' && accessType !== 'shareAuthor') {
if(accessType !== 'edit'){
brew.editId = undefined;
}
return brew;
@@ -81,8 +94,7 @@ app.get('/robots.txt', (req, res)=>{
app.get('/', (req, res, next)=>{
req.brew = {
text : welcomeText,
renderer : 'V3',
theme : '5ePHB'
renderer : 'V3'
},
req.ogMeta = { ...defaultMetaTags,
@@ -98,8 +110,7 @@ app.get('/', (req, res, next)=>{
app.get('/legacy', (req, res, next)=>{
req.brew = {
text : welcomeTextLegacy,
renderer : 'legacy',
theme : '5ePHB'
renderer : 'legacy'
},
req.ogMeta = { ...defaultMetaTags,
@@ -246,7 +257,6 @@ app.get('/user/:username', async (req, res, next)=>{
brew.pageCount = googleBrews[match].pageCount;
brew.renderer = googleBrews[match].renderer;
brew.version = googleBrews[match].version;
brew.webViewLink = googleBrews[match].webViewLink;
googleBrews.splice(match, 1);
}
}
@@ -257,9 +267,6 @@ app.get('/user/:username', async (req, res, next)=>{
}
req.brews = _.map(brews, (brew)=>{
// Clean up brew data
brew.title = brew.title?.trim();
brew.description = brew.description?.trim();
return sanitizeBrew(brew, ownAccount ? 'edit' : 'share');
});
@@ -267,11 +274,9 @@ app.get('/user/:username', async (req, res, next)=>{
});
//Edit Page
app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{
app.get('/edit/:id', asyncHandler(getBrew('edit')), (req, res, next)=>{
req.brew = req.brew.toObject ? req.brew.toObject() : req.brew;
req.userThemes = await(getUsersBrewThemes(req.account?.username));
req.ogMeta = { ...defaultMetaTags,
title : req.brew.title || 'Untitled Brew',
description : req.brew.description || 'No description.',
@@ -283,10 +288,10 @@ app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res,
splitTextStyleAndMetadata(req.brew);
res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save.
return next();
}));
});
//New Page from ID
app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{
//New Page
app.get('/new/:id', asyncHandler(getBrew('share')), (req, res, next)=>{
sanitizeBrew(req.brew, 'share');
splitTextStyleAndMetadata(req.brew);
const brew = {
@@ -295,36 +300,22 @@ app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res,
text : req.brew.text,
style : req.brew.style,
renderer : req.brew.renderer,
theme : req.brew.theme,
tags : req.brew.tags,
theme : req.brew.theme
};
req.brew = _.defaults(brew, DEFAULT_BREW);
req.userThemes = await(getUsersBrewThemes(req.account?.username));
req.ogMeta = { ...defaultMetaTags,
title : 'New',
description : 'Start crafting your homebrew on the Homebrewery!'
};
return next();
}));
//New Page
app.get('/new', asyncHandler(async(req, res, next)=>{
req.userThemes = await(getUsersBrewThemes(req.account?.username));
req.ogMeta = { ...defaultMetaTags,
title : 'New',
description : 'Start crafting your homebrew on the Homebrewery!'
};
return next();
}));
});
//Share Page
app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
const { brew } = req;
req.ogMeta = { ...defaultMetaTags,
title : req.brew.title || 'Untitled Brew',
description : req.brew.description || 'No description.',
@@ -332,23 +323,26 @@ app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, r
type : 'article'
};
// increase visitor view count, do not include visits by author(s)
if(!brew.authors.includes(req.account?.username)){
if(req.params.id.length > 12 && !brew._id) {
const googleId = brew.googleId;
const shareId = brew.shareId;
await GoogleActions.increaseView(googleId, shareId, 'share', brew)
.catch((err)=>{next(err);});
} else {
await HomebrewModel.increaseView({ shareId: brew.shareId });
}
};
brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share');
if(req.params.id.length > 12 && !brew._id) {
const googleId = brew.googleId;
const shareId = brew.shareId;
await GoogleActions.increaseView(googleId, shareId, 'share', brew)
.catch((err)=>{next(err);});
} else {
await HomebrewModel.increaseView({ shareId: brew.shareId });
}
sanitizeBrew(req.brew, 'share');
splitTextStyleAndMetadata(req.brew);
return next();
}));
//Print Page
app.get('/print/:id', asyncHandler(getBrew('share')), (req, res, next)=>{
sanitizeBrew(req.brew, 'share');
splitTextStyleAndMetadata(req.brew);
next();
});
//Account Page
app.get('/account', asyncHandler(async (req, res, next)=>{
const data = {};
@@ -383,7 +377,7 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
console.log(err);
});
data.accountDetails = {
data.uiItems = {
username : req.account.username,
issued : req.account.issued,
googleId : Boolean(req.account.googleId),
@@ -436,8 +430,7 @@ const renderPage = async (req, res)=>{
enable_v3 : config.get('enable_v3'),
enable_themes : config.get('enable_themes'),
config : configuration,
ogMeta : req.ogMeta,
userThemes : req.userThemes
ogMeta : req.ogMeta
};
const title = req.brew ? req.brew.title : '';
const page = await templateFn('homebrew', title, props)
@@ -472,17 +465,8 @@ const getPureError = (error)=>{
};
app.use(async (err, req, res, next)=>{
err.originalUrl = req.originalUrl;
console.error(err);
if(err.originalUrl?.startsWith('/api/')) {
// console.log('API error');
res.status(err.status || err.response?.status || 500).send(err);
return;
}
// console.log('non-API error');
const status = err.status || err.code || 500;
console.error(err);
req.ogMeta = { ...defaultMetaTags,
title : 'Error Page',

View File

@@ -1,4 +1,5 @@
/* eslint-disable max-lines */
const _ = require('lodash');
const googleDrive = require('@googleapis/drive');
const { nanoid } = require('nanoid');
const token = require('./token.js');
@@ -6,9 +7,7 @@ const config = require('./config.js');
let serviceAuth;
if(!config.get('service_account')){
const reset = '\x1b[0m'; // Reset to default style
const yellow = '\x1b[33m'; // yellow color
console.warn(`\n${yellow}No Google Service Account in config files - Google Drive integration will not be available.${reset}`);
console.log('No Google Service Account in config files - Google Drive integration will not be available.');
} else {
const keys = typeof(config.get('service_account')) == 'string' ?
JSON.parse(config.get('service_account')) :
@@ -19,7 +18,7 @@ if(!config.get('service_account')){
serviceAuth.scopes = ['https://www.googleapis.com/auth/drive'];
} catch (err) {
console.warn(err);
console.warn('Please make sure the Google Service Account is set up properly in your config files.');
console.log('Please make sure the Google Service Account is set up properly in your config files.');
}
}
@@ -107,7 +106,7 @@ const GoogleActions = {
const obj = await drive.files.list({
pageSize : 1000,
pageToken : NextPageToken || '',
fields : 'nextPageToken, files(id, name, description, createdTime, modifiedTime, properties, webViewLink)',
fields : 'nextPageToken, files(id, name, description, createdTime, modifiedTime, properties)',
q : 'mimeType != \'application/vnd.google-apps.folder\' and trashed = false'
})
.catch((err)=>{
@@ -140,8 +139,7 @@ const GoogleActions = {
published : file.properties.published ? file.properties.published == 'true' : false,
systems : [],
lang : file.properties.lang,
thumbnail : file.properties.thumbnail,
webViewLink : file.webViewLink
thumbnail : file.properties.thumbnail
};
});
return brews;

View File

@@ -8,16 +8,9 @@ const Markdown = require('../shared/naturalcrit/markdown.js');
const yaml = require('js-yaml');
const asyncHandler = require('express-async-handler');
const { nanoid } = require('nanoid');
const { splitTextStyleAndMetadata } = require('../shared/helpers.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) => {
// HomebrewModel.find().sort({ views: -1 }).limit(5).exec(function(err, brews) {
// cb(brews);
@@ -44,40 +37,6 @@ const api = {
}
return { id, googleId };
},
//Get array of any of this user's brews tagged with `meta:theme`
getUsersBrewThemes : async (username)=>{
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)=>{
// Create middleware with the accessType passed in as part of the scope
return async (req, res, next)=>{
@@ -95,10 +54,6 @@ const api = {
});
stub = stub?.toObject();
if(stub?.lock?.locked && accessType != 'edit') {
throw { HBErrorCode: '100', code: stub.lock.code, message: stub.lock.shareMessage, brewId: stub.shareId, brewTitle: stub.title };
}
// If there is a google id, try to find the google brew
if(!stubOnly && (googleId || stub?.googleId)) {
let googleError;
@@ -124,9 +79,9 @@ const api = {
if(accessType === 'edit' && (authorsExist && !(isAuthor || isInvited))) {
const accessError = { name: 'Access Error', status: 401 };
if(req.account){
throw { ...accessError, message: 'User is not an Author', HBErrorCode: '03', authors: stub.authors, brewTitle: stub.title, shareId: stub.shareId };
throw { ...accessError, message: 'User is not an Author', HBErrorCode: '03', authors: stub.authors, brewTitle: stub.title };
}
throw { ...accessError, message: 'User is not logged in', HBErrorCode: '04', authors: stub.authors, brewTitle: stub.title, shareId: stub.shareId };
throw { ...accessError, message: 'User is not logged in', HBErrorCode: '04', authors: stub.authors, brewTitle: stub.title };
}
// If after all of that we still don't have a brew, throw an exception
@@ -198,9 +153,6 @@ const api = {
brew.text = api.mergeBrewText(brew);
_.defaults(brew, DEFAULT_BREW);
brew.title = brew.title.trim();
brew.description = brew.description.trim();
},
newGoogleBrew : async (account, brew, res)=>{
const oAuth2Client = GoogleActions.authCheck(account, res);
@@ -250,62 +202,6 @@ const api = {
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, in render order, and an array
of snippets ( currently empty )
Important parameter members:
req.params.id: This is the shareId ( User theme ) or name ( static theme )
loaded first.
req.params.renderer: This is the Markdown+ version for the static theme. If a
User theme the value will come from the User Theme metadata.
*/
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)=>{
// 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);
@@ -321,8 +217,6 @@ const api = {
const { saveToGoogle, removeFromGoogle } = req.query;
let afterSave = async ()=>true;
brew.title = brew.title.trim();
brew.description = brew.description.trim() || '';
brew.text = api.mergeBrewText(brew);
if(brew.googleId && removeFromGoogle) {
@@ -466,6 +360,5 @@ 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.delete('/api/:id', asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
module.exports = api;

View File

@@ -14,9 +14,6 @@ describe('Tests for api', ()=>{
let saved;
beforeEach(()=>{
jest.resetModules();
jest.restoreAllMocks();
saved = undefined;
saveFunc = jest.fn(async function() {
saved = { ...this, _id: '1' };
@@ -48,9 +45,8 @@ describe('Tests for api', ()=>{
model.mockImplementation((brew)=>modelBrew(brew));
res = {
status : jest.fn(()=>res),
send : jest.fn(()=>{}),
setHeader : jest.fn(()=>{})
status : jest.fn(()=>res),
send : jest.fn(()=>{})
};
api = require('./homebrew.api');
@@ -85,6 +81,10 @@ describe('Tests for api', ()=>{
};
});
afterEach(()=>{
jest.restoreAllMocks();
});
describe('getId', ()=>{
it('should return only id if google id is not present', ()=>{
const { id, googleId } = api.getId({
@@ -117,7 +117,7 @@ describe('Tests for api', ()=>{
id : '123456789012345678901234567890123abcdefghijkl'
}
});
expect(googleId).toEqual('123456789012345678901234567890123');
expect(id).toEqual('abcdefghijkl');
});
@@ -128,7 +128,7 @@ describe('Tests for api', ()=>{
id : '123456789012345678901234567890123abcdefghij'
}
});
expect(googleId).toEqual('123456789012345678901234567890123');
expect(id).toEqual('abcdefghij');
});
@@ -298,18 +298,6 @@ describe('Tests for api', ()=>{
expect(model.get).toHaveBeenCalledWith({ shareId: '1' });
expect(google.getGoogleBrew).toHaveBeenCalledWith('2', '1', 'share');
});
it('access is denied to a locked brew', async()=>{
const lockBrew = { title: 'test brew', shareId: '1', lock: { locked: true, code: 404, shareMessage: 'brew locked' } };
model.get = jest.fn(()=>toBrewPromise(lockBrew));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
const fn = api.getBrew('share', false);
const req = { brew: {} };
const next = jest.fn();
await expect(fn(req, null, next)).rejects.toEqual({ 'HBErrorCode': '100', 'brewId': '1', 'brewTitle': 'test brew', 'code': 404, 'message': 'brew locked' });
});
});
describe('mergeBrewText', ()=>{
@@ -581,121 +569,6 @@ 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', ()=>{
it('should handle case where fetching the brew returns an error', async ()=>{
api.getBrew = jest.fn(()=>async ()=>{ throw { message: 'err', HBErrorCode: '02' }; });

View File

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

View File

@@ -1,57 +0,0 @@
const _ = require('lodash');
const yaml = require('js-yaml');
const request = require('../client/homebrew/utils/request-middleware.js');
const splitTextStyleAndMetadata = (brew)=>{
brew.text = brew.text.replaceAll('\r\n', '\n');
if(brew.text.startsWith('```metadata')) {
const index = brew.text.indexOf('```\n\n');
const metadataSection = brew.text.slice(12, index - 1);
const metadata = yaml.load(metadataSection);
Object.assign(brew, _.pick(metadata, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang']));
brew.text = brew.text.slice(index + 5);
}
if(brew.text.startsWith('```css')) {
const index = brew.text.indexOf('```\n\n');
brew.style = brew.text.slice(7, index - 1);
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)=>{
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 = {
splitTextStyleAndMetadata,
printCurrentBrew,
fetchThemeBundle,
};

View File

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

View File

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

View File

@@ -1,84 +0,0 @@
const diceFont = require('../../../themes/fonts/iconFonts/diceFont.js');
const elderberryInn = require('../../../themes/fonts/iconFonts/elderberryInn.js');
const fontAwesome = require('../../../themes/fonts/iconFonts/fontAwesome.js');
const gameIcons = require('../../../themes/fonts/iconFonts/gameIcons.js');
const emojis = {
...diceFont,
...elderberryInn,
...fontAwesome,
...gameIcons
};
const showAutocompleteEmoji = function(CodeMirror, editor) {
CodeMirror.commands.autocomplete = function(editor) {
editor.showHint({
completeSingle : false,
hint : function(editor) {
const cursor = editor.getCursor();
const line = cursor.line;
const lineContent = editor.getLine(line);
const start = lineContent.lastIndexOf(':', cursor.ch - 1) + 1;
const end = cursor.ch;
const currentWord = lineContent.slice(start, end);
const list = Object.keys(emojis).filter(function(emoji) {
return emoji.toLowerCase().indexOf(currentWord.toLowerCase()) >= 0;
}).sort((a, b)=>{
const lowerA = a.replace(/\d+/g, function(match) { // Temporarily convert any numbers in emoji string
return match.padStart(4, '0'); // to 4-digits, left-padded with 0's, to aid in
}).toLowerCase(); // sorting numbers, i.e., "d6, d10, d20", not "d10, d20, d6"
const lowerB = b.replace(/\d+/g, function(match) { // Also make lowercase for case-insensitive alpha sorting
return match.padStart(4, '0');
}).toLowerCase();
if(lowerA < lowerB)
return -1;
return 1;
}).map(function(emoji) {
return {
text : `${emoji}:`, // Text to output to editor when option is selected
render : function(element, self, data) { // How to display the option in the dropdown
const div = document.createElement('div');
div.innerHTML = `<i class="emojiPreview ${emojis[emoji]}"></i> ${emoji}`;
element.appendChild(div);
}
};
});
return {
list : list.length ? list : [],
from : CodeMirror.Pos(line, start),
to : CodeMirror.Pos(line, end)
};
}
});
};
editor.on('inputRead', function(instance, change) {
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
// Get the text from the start of the line to the cursor
const textToCursor = line.slice(0, cursor.ch);
// Do not autosuggest emojis in curly span/div/injector properties
if(line.includes('{')) {
const curlyToCursor = textToCursor.slice(textToCursor.indexOf(`{`));
const curlySpanRegex = /{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1$/g;
if(curlySpanRegex.test(curlyToCursor))
return;
}
// Check if the text ends with ':xyz'
if(/:[^\s:]+$/.test(textToCursor)) {
CodeMirror.commands.autocomplete(editor);
}
});
};
module.exports = {
showAutocompleteEmoji
};

View File

@@ -0,0 +1,36 @@
let CodeMirror;
if(typeof navigator !== 'undefined'){
CodeMirror = require('codemirror');
//Language Modes
require('codemirror/mode/gfm/gfm.js'); //Github flavoured markdown
require('codemirror/mode/css/css.js');
require('codemirror/mode/javascript/javascript.js');
//Addons
//Code folding
require('codemirror/addon/fold/foldcode.js');
require('codemirror/addon/fold/foldgutter.js');
//Search and replace
require('codemirror/addon/search/search.js');
require('codemirror/addon/search/searchcursor.js');
require('codemirror/addon/search/jump-to-line.js');
require('codemirror/addon/search/match-highlighter.js');
require('codemirror/addon/search/matchesonscrollbar.js');
require('codemirror/addon/dialog/dialog.js');
//Trailing space highlighting
// require('codemirror/addon/edit/trailingspace.js');
//Active line highlighting
// require('codemirror/addon/selection/active-line.js');
//Scroll past last line
require('codemirror/addon/scroll/scrollpastend.js');
//Auto-closing
//XML code folding is a requirement of the auto-closing tag feature and is not enabled
require('codemirror/addon/fold/xml-fold.js');
require('codemirror/addon/edit/closetag.js');
const foldCode = require('./helpers/fold-code');
foldCode.registerHomebreweryHelper(CodeMirror);
}
module.exports = CodeMirror;

View File

@@ -3,48 +3,16 @@ require('./codeEditor.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const closeTag = require('./close-tag');
const autoCompleteEmoji = require('./autocompleteEmoji');
const cx = require('classnames');
const closeTag = require('./helpers/close-tag');
const Hints = require('./helpers/widget-elements/hints/hints.jsx');
const CodeMirror = require('./code-mirror.js');
let CodeMirror;
if(typeof window !== 'undefined'){
CodeMirror = require('codemirror');
//Language Modes
require('codemirror/mode/gfm/gfm.js'); //Github flavoured markdown
require('codemirror/mode/css/css.js');
require('codemirror/mode/javascript/javascript.js');
//Addons
//Code folding
require('codemirror/addon/fold/foldcode.js');
require('codemirror/addon/fold/foldgutter.js');
//Search and replace
require('codemirror/addon/search/search.js');
require('codemirror/addon/search/searchcursor.js');
require('codemirror/addon/search/jump-to-line.js');
require('codemirror/addon/search/match-highlighter.js');
require('codemirror/addon/search/matchesonscrollbar.js');
require('codemirror/addon/dialog/dialog.js');
//Trailing space highlighting
// require('codemirror/addon/edit/trailingspace.js');
//Active line highlighting
// require('codemirror/addon/selection/active-line.js');
//Scroll past last line
require('codemirror/addon/scroll/scrollpastend.js');
//Auto-closing
//XML code folding is a requirement of the auto-closing tag feature and is not enabled
require('codemirror/addon/fold/xml-fold.js');
require('codemirror/addon/edit/closetag.js');
//Autocompletion
require('codemirror/addon/hint/show-hint.js');
const foldCode = require('./fold-code');
foldCode.registerHomebreweryHelper(CodeMirror);
}
const themeWidgets = require('../../../themes/V3/5ePHB/widgets');
const CodeEditor = createClass({
displayName : 'CodeEditor',
hintsRef : React.createRef(),
getDefaultProps : function() {
return {
language : '',
@@ -52,18 +20,19 @@ const CodeEditor = createClass({
wrap : true,
onChange : ()=>{},
enableFolding : true,
editorTheme : 'default'
};
},
getInitialState : function() {
return {
docs : {}
docs : {},
widgetUtils : {},
widgets : {},
hints : [],
hintsField : undefined,
};
},
editor : React.createRef(null),
componentDidMount : function() {
this.buildEditor();
const newDoc = CodeMirror.Doc(this.props.value, this.props.language);
@@ -97,13 +66,12 @@ const CodeEditor = createClass({
this.codeMirror.setOption('foldOptions', false);
}
if(prevProps.editorTheme !== this.props.editorTheme){
this.codeMirror.setOption('theme', this.props.editorTheme);
}
this.state.widgetUtils.updateWidgetGutter();
this.state.widgetUtils.updateAllLineWidgets();
},
buildEditor : function() {
this.codeMirror = CodeMirror(this.editor.current, {
this.codeMirror = CodeMirror(this.refs.editor, {
lineNumbers : true,
lineWrapping : this.props.wrap,
indentWithTabs : false,
@@ -116,10 +84,6 @@ const CodeEditor = createClass({
'Shift-Tab' : this.dedent,
'Ctrl-B' : this.makeBold,
'Cmd-B' : this.makeBold,
'Shift-Ctrl-=' : this.makeSuper,
'Shift-Cmd-=' : this.makeSuper,
'Ctrl-=' : this.makeSub,
'Cmd-=' : this.makeSub,
'Ctrl-I' : this.makeItalic,
'Cmd-I' : this.makeItalic,
'Ctrl-U' : this.makeUnderline,
@@ -168,11 +132,10 @@ const CodeEditor = createClass({
},
foldGutter : true,
foldOptions : this.foldOptions(this.codeMirror),
gutters : ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
gutters : ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'widget-gutter'],
autoCloseTags : true,
styleActiveLine : true,
showTrailingSpace : false,
theme : this.props.editorTheme
// specialChars : / /,
// specialCharPlaceholder : function(char) {
// const el = document.createElement('span');
@@ -181,14 +144,71 @@ const CodeEditor = createClass({
// return el;
// }
});
// Add custom behaviors (auto-close curlies and auto-complete emojis)
closeTag.autoCloseCurlyBraces(CodeMirror, this.codeMirror);
autoCompleteEmoji.showAutocompleteEmoji(CodeMirror, this.codeMirror);
this.setState({
widgetUtils : require('./helpers/widgets')(themeWidgets, this.codeMirror, (hints, field)=>{
this.setState({
hints,
hintsField : field
});
})
});
// Note: codeMirror passes a copy of itself in this callback. cm === this.codeMirror. Either one works.
this.codeMirror.on('change', (cm)=>{this.props.onChange(cm.getValue());});
this.codeMirror.on('change', (cm)=>{
this.props.onChange(cm.getValue());
this.state.widgetUtils.updateWidgetGutter();
});
this.codeMirror.on('cursorActivity', (cm)=>{
const { line } = cm.getCursor();
for (const key in this.state.widgets) {
if(key != line) {
this.state.widgetUtils.removeLineWidget(key, this.state.widgets[key]);
}
}
this.setState({
hints : [],
hintsField : undefined
});
const { widgets } = this.codeMirror.lineInfo(line);
if(!widgets) {
const widget = this.state.widgetUtils.updateLineWidgets(line);
if(widget) {
this.setState({
widgets : {
[line] : widget
}
});
}
}
});
this.updateSize();
this.codeMirror.on('gutterClick', (cm, n)=>{
// Open line widgets when 'widget-gutter' marker clicked
if(this.codeMirror.lineInfo(n).gutterMarkers?.['widget-gutter']) {
const { widgets } = this.codeMirror.lineInfo(n);
if(!widgets) {
const widget = this.state.widgetUtils.updateLineWidgets(n);
if(widget) {
this.setState({
widgets : { ...this.state.widgets, [n]: widget }
});
}
} else {
for (const widget of widgets) {
this.state.widgetUtils.removeLineWidget(n, widget);
}
this.setState({
hints : [],
hintsField : undefined
});
}
}
});
},
indent : function () {
@@ -230,25 +250,6 @@ const CodeEditor = createClass({
}
},
makeSuper : function() {
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 1) === '^' && selection.slice(-1) === '^';
this.codeMirror.replaceSelection(t ? selection.slice(1, -1) : `^${selection}^`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror.getCursor();
this.codeMirror.setCursor({ line: cursor.line, ch: cursor.ch - 1 });
}
},
makeSub : function() {
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 2) === '^^' && selection.slice(-2) === '^^';
this.codeMirror.replaceSelection(t ? selection.slice(2, -2) : `^^${selection}^^`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror.getCursor();
this.codeMirror.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
}
},
makeNbsp : function() {
this.codeMirror.replaceSelection('&nbsp;', 'end');
},
@@ -439,13 +440,19 @@ const CodeEditor = createClass({
}
};
},
keyDown : function(e) {
if(this.hintsRef.current) {
this.hintsRef.current.keyDown(e);
}
},
//----------------------//
render : function(){
return <>
<link href={`../homebrew/cm-themes/${this.props.editorTheme}.css`} type='text/css' rel='stylesheet' />
<div className='codeEditor' ref={this.editor} style={this.props.style}/>
</>;
const { hints, hintsField } = this.state;
return <React.Fragment>
<div className='codeEditor' ref='editor' style={this.props.style} onKeyDown={this.keyDown}/>
<Hints ref={this.hintsRef} hints={hints} field={hintsField}/>
</React.Fragment>;
}
});

View File

@@ -3,12 +3,7 @@
@import (less) 'codemirror/addon/search/matchesonscrollbar.css';
@import (less) 'codemirror/addon/dialog/dialog.css';
@import (less) 'codemirror/addon/hint/show-hint.css';
//Icon fonts included so they can appear in emoji autosuggest dropdown
@import (less) './themes/fonts/iconFonts/diceFont.less';
@import (less) './themes/fonts/iconFonts/elderberryInn.less';
@import (less) './themes/fonts/iconFonts/gameIcons.less';
@import (less) './themes/fonts/iconFonts/fontAwesome.less';
@import 'naturalcrit/styles/colors.less';
@keyframes sourceMoveAnimation {
50% {background-color: red; color: white;}
@@ -16,9 +11,6 @@
}
.codeEditor{
@media screen and (pointer : coarse) {
font-size : 16px;
}
.CodeMirror-foldmarker {
font-family: inherit;
text-shadow: none;
@@ -31,17 +23,6 @@
animation-duration: 0.4s;
}
.CodeMirror-vscrollbar {
&::-webkit-scrollbar {
width: 20px;
}
&::-webkit-scrollbar-thumb {
width: 20px;
background: linear-gradient(90deg, #858585 15px, #808080 15px);
}
}
//.cm-tab {
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right;
//}
@@ -51,9 +32,51 @@
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right;
// }
//}
}
.emojiPreview {
font-size: 1.5em;
line-height: 1.2em;
}
.widget-gutter {
width: .7em;
}
.snippet-options-widget {
padding: 2px 0;
>div {
display: flex;
flex-wrap: wrap;
}
* {
margin: 0 2px 2px 2px;
}
input {
max-width: 10vw;
}
}
.widget-field {
border: 2px solid #ddd;
&.default {
background-color: @purple;
border: 2px solid @purple;
color: white;
>input {
background-color: @purple;
color: white;
}
}
&.suggested {
background-color: #ddd;
border: 2px dashed grey;
color: grey;
>input {
background-color: #ddd;
color: black;
}
}
}
}

View File

@@ -0,0 +1,48 @@
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
require('./checkbox.less');
const CodeMirror = require('../../../code-mirror.js');
const Checkbox = createClass({
getDefaultProps : function() {
return {
value : '',
prefix : '',
cm : {},
n : -1,
default : false
};
},
handleChange : function (e) {
const { cm, n, value, prefix } = this.props;
const { text } = cm.lineInfo(n);
const updatedPrefix = `{{${prefix}`;
if(e.target?.checked)
cm.replaceRange(`,${value}`, CodeMirror.Pos(n, updatedPrefix.length), CodeMirror.Pos(n, updatedPrefix.length), '+insert');
else {
const start = text.indexOf(`,${value}`);
if(start > -1)
cm.replaceRange('', CodeMirror.Pos(n, start), CodeMirror.Pos(n, start + value.length + 1), '-delete');
}
},
render : function() {
const { cm, n, value, prefix, def } = this.props;
const { text } = cm.lineInfo(n);
const id = [prefix, value, n].join('-');
let className = 'widget-field widget-checkbox';
if(def) {
className += ' default';
}
return <React.Fragment>
<div className={className}>
<input type='checkbox' id={id} onChange={this.handleChange} checked={_.includes(text, `,${value}`)}/>
<label htmlFor={id}>{_.startCase(value)}</label>
</div>
</React.Fragment>;
}
});
module.exports = Checkbox;

View File

@@ -0,0 +1,7 @@
.widget-checkbox {
display: inline-block;
flex: 0 0 auto;
background-color: #ddd;
border-radius: 10px;
padding: 4px 2px;
}

View File

@@ -0,0 +1,95 @@
require('./color-selector.less');
const React = require('react');
const createClass = require('create-react-class');
const { PATTERNS, STYLE_FN, SNIPPET_TYPE } = require('../constants');
const CodeMirror = require('../../../code-mirror');
const debounce = require('lodash.debounce');
const ColorSelector = createClass({
getDefaultProps : function() {
return {
field : {},
cm : {},
n : undefined,
text : '',
def : false
};
},
getInitialState : function() {
return {
value : ''
};
},
componentDidMount : function() {
const { field, text } = this.props;
const pattern = PATTERNS.field[field.type](field.name);
const [_, __, value] = text.match(pattern) ?? [];
this.setState({
value : value,
});
},
componentDidUpdate({ text }) {
const { field } = this.props;
if(this.props.text !== text) {
const pattern = PATTERNS.field[field.type](field.name);
const [_, __, value] = this.props.text.match(pattern) ?? [];
this.setState({
value,
});
}
},
onChange : function(e) {
const { cm, text, field, n, snippetType } = this.props;
const pattern = PATTERNS.field[field.type](field.name);
const [_, label, current] = text.match(pattern) ?? [null, field.name, ''];
let index = text.indexOf(`${label}:${current}`);
while (index !== -1 && text[index - 1] === '-') {
index = text.indexOf(`${label}:${current}`, index + 1);
}
let value = e.target.value;
if(index === -1) {
if(snippetType === SNIPPET_TYPE.INLINE) {
index = text.indexOf('}');
}
index = index === -1 ? text.length : index;
value = `,${field.name}:${value}`;
} else {
index = index + 1 + field.name.length;
}
cm.replaceRange(value, CodeMirror.Pos(n, index), CodeMirror.Pos(n, index + current.length), '+insert');
this.setState({
value : e.target.value,
});
},
debounce : debounce((self, e)=>self.onChange(e), 300),
onChangeDebounce : function(e) {
this.setState({
value : e.target.value,
});
this.debounce(this, e);
},
render : function() {
const { field, n, text, def } = this.props;
const { value } = this.state;
const style = STYLE_FN(value);
const id = `${field?.name}-${n}`;
const pattern = PATTERNS.field[field.type](field.name);
const [_, label, __] = text.match(pattern) ?? [null, undefined, ''];
let className = 'widget-field color-selector';
if(!label) {
className += ' suggested';
}
if(def) {
className += ' default';
}
return <React.Fragment>
<div className={className}>
<label htmlFor={id}>{field.name}:</label>
<input className='color' type='color' value={value} onChange={this.onChangeDebounce}/>
<input id={id} className='text' type='text' style={style} value={value} onChange={this.onChange}/>
</div>
</React.Fragment>;
}
});
module.exports = ColorSelector;

View File

@@ -0,0 +1,8 @@
.color-selector {
.color {
height: 17px;
width: 13px;
padding: 0;
margin: 0;
}
}

View File

@@ -0,0 +1,50 @@
const _ = require('lodash');
export const UNITS = ['cm', 'mm', 'in', 'px', 'pt', 'pc', 'em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', '%'];
export const HINT_TYPE = {
VALUE : 0,
NUMBER_SUFFIX : 1
};
export const SNIPPET_TYPE = {
BLOCK : 0,
INLINE : 1,
INJECTOR : 2,
};
export const FIELD_TYPE = {
TEXT : 0,
CHECKBOX : 1,
IMAGE_SELECTOR : 2,
COLOR_SELECTOR : 3,
};
const textField = (name)=>new RegExp(`[{,;](${name}):([^};,"\\(]*\\((?!,)[^};"\\)]*\\)|"[^},;"]*"|[^},;]*)`);
export const PATTERNS = {
snippet : {
[SNIPPET_TYPE.BLOCK] : (name)=>new RegExp(`^{{${name}(?:[^a-zA-Z].*)?`),
[SNIPPET_TYPE.INLINE] : (name)=>new RegExp(`{{${name}`),
[SNIPPET_TYPE.INJECTOR] : ()=>new RegExp(`^\\!\\[(?:[a-zA-Z -]+)?\\]\\(.*\\).*{[a-zA-Z0-9:, "'-]+}$`),
},
field : {
[FIELD_TYPE.TEXT] : textField,
[FIELD_TYPE.IMAGE_SELECTOR] : (name)=>new RegExp(`{{(${name})(\\d*)`),
[FIELD_TYPE.COLOR_SELECTOR] : textField
},
collectStyles : new RegExp(`(?:([a-zA-Z-]+):(?!\\/))+`, 'g'),
};
export const NUMBER_PATTERN = new RegExp(`([^-\\d]*)([-\\d]+)(${UNITS.join('|')})?(.*)`);
export const fourDigitNumberFromValue = (value)=>typeof value === 'number' ? (()=>{
const str = String(value);
return _.range(0, 4 - str.length).map(()=>'0').join('') + str;
})() : value;
const DEFAULT_WIDTH = '30px';
export const STYLE_FN = (value, extras = {})=>({
width : `calc(${value?.length ?? 0}ch + ${value?.length ? `${DEFAULT_WIDTH} / 2` : DEFAULT_WIDTH})`,
...extras
});

View File

@@ -0,0 +1,128 @@
const React = require('react');
const createClass = require('create-react-class');
const { NUMBER_PATTERN } = require('../constants');
const Hints = createClass({
hintsRef : React.createRef(),
activeHintRef : React.createRef(),
getDefaultProps : function() {
return {
hints : [],
field : undefined,
};
},
getInitialState : function() {
return {
activeHint : 0
};
},
componentDidUpdate : function({ hints }) {
const hintsLength = this.props.hints.length;
if(hintsLength - 1 < this.state.activeHint && hintsLength !== hints.length) {
this.setState({
activeHint : hintsLength === 0 ? 0 : hintsLength - 1
});
return;
}
if(this.hintsRef.current && this.activeHintRef.current) {
const offset = this.activeHintRef.current.offsetTop;
const scrollTop = this.hintsRef.current.scrollTop;
if(scrollTop + 50 < offset || scrollTop + 50 > offset) {
this.hintsRef.current.scrollTo({
top : offset - 50,
behavior : 'smooth'
});
}
}
},
componentDidMount : function() {},
keyDown : function(e) {
const { code } = e;
const { activeHint } = this.state;
const { hints, field } = this.props;
const match = field?.state?.value?.match(NUMBER_PATTERN);
if(code === 'ArrowDown') {
e.preventDefault();
if(!match || !match?.at(3)) {
this.setState({
activeHint : activeHint === hints.length - 1 ? 0 : activeHint + 1
});
}
} else if(code === 'ArrowUp') {
e.preventDefault();
if(!match || !match?.at(3)) {
this.setState({
activeHint : activeHint === 0 ? hints.length - 1 : activeHint - 1
});
}
} else if(code === 'Enter') {
e.preventDefault();
if(!match || !match?.at(3)) {
field?.hintSelected(hints[activeHint]);
this.setState({
activeHint : 0
});
}
}
},
render : function() {
const { activeHint } = this.state;
const { hints, field } = this.props;
if(!field) return null;
const bounds = field.fieldRef[field.state.id]?.current?.getBoundingClientRect();
if(!bounds) return null;
const hintElements = hints
.filter((h)=>h.hint !== field.state.value)
.map((h, i)=>{
let className = 'CodeMirror-hint';
if(activeHint === i) {
className += ' CodeMirror-hint-active';
return <li key={i}
role={'option'}
className={className}
onMouseDown={(e)=>field.hintSelected(h, e)}
ref={this.activeHintRef}>
{h.hint}
</li>;
}
return <li key={i}
role={'option'}
className={className}
onMouseDown={(e)=>field.hintSelected(h, e)}>
{h.hint}
</li>;
});
let style = {
display : 'none'
};
if(hintElements.length > 0) {
style = {
...style,
display : 'block',
top : `${bounds.top - 5}px`,
left : `${bounds.left}px`
};
}
return <React.Fragment>
<ul role={'listbox'}
id={'hints'}
aria-expanded={true}
className={'CodeMirror-hints default'}
style={style}
ref={this.hintsRef}>
{hintElements}
</ul>
</React.Fragment>;
}
});
module.exports = Hints;

View File

@@ -0,0 +1,92 @@
require('./image-selector.less');
const React = require('react');
const createClass = require('create-react-class');
const { Modal, modalHelpers } = require('../modal/modal.jsx');
const { PATTERNS } = require('../constants.js');
const CodeMirror = require('../../../code-mirror.js');
const _ = require('lodash');
const ImageSelector = createClass({
modalRef : React.createRef(),
getDefaultProps : function () {
return {
field : {},
cm : {},
n : undefined
};
},
getInitialState : function() {
return {
selected : undefined,
};
},
componentDidMount : function() {
modalHelpers.mount(this);
},
componentDidUpdate : function() {
const { name, preview, values } = this.props.field;
const { selected } = this.state;
const images = values.map((v, i)=>{
const className = String(selected) === String(v) ? 'selected' : '';
return <img key={i} className={className} src={preview(v)} alt={`${name} image ${v}`} onClick={()=>this.select(v)}/>;
});
this.state.modalRoot?.render(<Modal ref={this.modalRef} header={_.startCase(name)} save={this.save}>
<div className={'images'}>
{images}
</div>
</Modal>);
},
componentWillUnmount : function() {
modalHelpers.unmount(this);
},
save : function() {
const { cm, field, n } = this.props;
const { text } = cm.lineInfo(n);
const pattern = PATTERNS.field[field.type](field.name);
const [fullmatch, label, current] = text.match(pattern);
if(!fullmatch) {
console.warn('something is wrong... please report this warning with a screenshot');
return;
}
const currentText = `${label}${current ?? ''}`;
const index = 2;
const value = label + this.state.selected;
cm.replaceRange(value, CodeMirror.Pos(n, index), CodeMirror.Pos(n, index + currentText.length), '+insert');
},
select : function(value) {
this.setState({
selected : value
});
},
showModal : function() {
const { cm, field, n } = this.props;
const { text } = cm.lineInfo(n);
const pattern = PATTERNS.field[field.type](field.name);
const [fullmatch, _, current] = text.match(pattern);
if(!fullmatch) {
return;
}
this.setState({
selected : current
});
this.modalRef.current.setVisible(true);
},
render : function () {
return <React.Fragment>
<button onClick={this.showModal}>Select Image</button>
</React.Fragment>;
}
});
module.exports = ImageSelector;

View File

@@ -0,0 +1,23 @@
.images {
display: flex;
flex-direction: row;
flex-wrap: wrap;
max-height: 60vh;
overflow-y: scroll;
img {
flex: 0 0 auto;
max-width: 10vw;
max-height: 20vh;
border-radius: 10px;
&:hover {
background-color: rgba(0, 0, 0, .1);
}
&.selected {
background-color: rgba(0, 0, 0, .175);
}
}
}

View File

@@ -0,0 +1,11 @@
const Text = require('./text/text.jsx');
const Checkbox = require('./checkbox/checkbox.jsx');
const ImageSelector = require('./image-selector/image-selector.jsx');
const ColorSelector = require('./color-selector/color-selector.jsx');
module.exports = {
Text : Text,
Checkbox : Checkbox,
ImageSelector : ImageSelector,
ColorSelector : ColorSelector,
};

View File

@@ -0,0 +1,75 @@
require('./modal.less');
const React = require('react');
const ReactDOMClient = require('react-dom/client');
const createClass = require('create-react-class');
const Modal = createClass({
getDefaultProps : function () {
return {
header : '',
save : ()=>{},
};
},
getInitialState : function() {
return {
visible : false,
};
},
setVisible : function(visible) {
this.setState({
visible
});
},
save : function() {
this.props.save();
this.setVisible(false);
},
render : function () {
const { children, header } = this.props;
const { visible } = this.state;
return <React.Fragment>
{visible ? <div className={'bg-cover'}>
<div className={'modal'}>
<h1>{header}</h1>
<hr/>
{children}
<div className={'action-row'}>
<button id={'save'} onClick={()=>this.save()}>Save</button>
<button id={'cancel'} onClick={()=>this.setVisible(false)}>Cancel</button>
</div>
</div>
</div> : null}
</React.Fragment>;
}
});
module.exports = {
/*
* Requirements:
* - modalRef member variable
* - should be re-rendered via `this.state.modalRoot?.render` in `componentDidUpdate`
*/
Modal,
modalHelpers : {
// should be called in `componentDidMount`
// `self` should be passed as the component instance (`this`)
mount : (self)=>{
const el = document.createElement('div');
const root = ReactDOMClient.createRoot(el);
document.querySelector('body').append(el);
self.setState({
el,
modalRoot : root
});
},
// should be called in `componentWillUnmount`
// `self` should be passed as the component instance (`this`)
unmount : (self)=>{
self.state.el.remove();
}
}
};

View File

@@ -0,0 +1,58 @@
@import 'naturalcrit/styles/colors.less';
.bg-cover {
width: 100vw;
height: 100vh;
position: absolute;
z-index: 10000000;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, .5);
}
.modal {
position: absolute;
top: 10vh;
left: 25vw;
width: 50vw;
min-height: 50vh;
max-height: 80vh;
background-color: #fff;
border-radius: 10px;
box-shadow: 5px 5px 50px black;
display: flex;
flex-direction: column;
justify-content: space-between;
h1 {
margin-top: 5px;
margin-left: 5px;
font-size: 2em;
}
>* {
flex: 0 0 auto;
}
.action-row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: left;
>* {
flex: 0 0 auto;
margin-left: 5px;
}
}
button {
&#cancel {
background-color: @redLight;
&:hover {
background-color: @red;
}
}
}
}

View File

@@ -0,0 +1,165 @@
require('./text.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const { NUMBER_PATTERN, HINT_TYPE, PATTERNS, STYLE_FN, FIELD_TYPE, SNIPPET_TYPE } = require('../constants');
const CodeMirror = require('../../../code-mirror.js');
const Text = createClass({
fieldRef : {},
getDefaultProps : function() {
return {
field : {},
text : '',
n : 0,
setHints : ()=>{},
onChange : ()=>{},
getStyleHints : ()=>{},
def : false,
snippetType : -1
};
},
getInitialState : function() {
return {
value : '',
id : ''
};
},
componentDidUpdate : function({ text }, { value }) {
if(this.props.text !== text) {
const { field, n } = this.props;
const pattern = PATTERNS.field[field.type](field.name);
const [_, __, value] = this.props.text.match(pattern) ?? [];
this.setState({
value : value,
id : `${field?.name}-${n}`
});
}
if(this.state.value !== value) {
const { field } = this.props;
this.props.setHints(this, field.hints ? this.props.getStyleHints(field, this.state.value) : []);
}
},
componentDidMount : function() {
const { field, text, n } = this.props;
const id = `${field?.name}-${n}`;
const pattern = PATTERNS.field[field.type](field.name);
const [_, __, value] = text.match(pattern) ?? [];
this.setState({
value : value,
id
});
this.fieldRef[id] = React.createRef();
},
componentWillUnmount : function() {
this.fieldRef = undefined;
this.fieldRef = {};
this.fieldRef[this.state.id]?.remove();
},
setFocus : function(e) {
const { type } = e;
const { field } = this.props;
this.props.setHints(this, type === 'focus' && field.hints ? this.props.getStyleHints(field, this.state.value) : []);
},
hintSelected : function(h, e) {
let value;
if(h?.type === HINT_TYPE.VALUE) {
value = h.hint;
} else if(h?.type === HINT_TYPE.NUMBER_SUFFIX) {
const match = this.state.value.match(NUMBER_PATTERN);
let suffix = match?.at(4) ?? '';
for (const char of h.hint) {
if(suffix.at(0) === char) {
suffix = suffix.slice(1);
}
}
value = `${match?.at(1) ?? ''}${match?.at(2) ?? ''}${h.hint}${suffix}`;
}
this.onChange({
target : {
value
}
});
},
keyDown : function(e) {
const { code } = e;
const { field } = this.props;
const { value } = this.state;
const match = value?.match(NUMBER_PATTERN);
if(code === 'ArrowDown') {
if(match && CSS.supports(field.name, value)) {
e.preventDefault();
this.onChange({
target : {
value : `${match.at(1) ?? ''}${Number(match[2]) - field.increment}${match[3] ?? ''}${match.at(4) ?? ''}`
}
});
}
} else if(code === 'ArrowUp') {
if(match && CSS.supports(field.name, value)) {
e.preventDefault();
this.onChange({
target : {
value : `${match.at(1) ?? ''}${Number(match[2]) + field.increment}${match[3] ?? ''}${match.at(4) ?? ''}`
}
});
}
}
},
onChange : function (e){
const { cm, text, field, n, snippetType } = this.props;
const pattern = PATTERNS.field[field.type](field.name);
const [_, label, current] = text.match(pattern) ?? [null, field.name, ''];
let index = text.indexOf(`${label}:${current}`);
let value = e.target.value;
if(index === -1) {
if(snippetType === SNIPPET_TYPE.INLINE) {
index = text.indexOf('}');
}
index = index === -1 ? text.length : index;
value = `,${field.name}:${value}`;
} else {
index = index + 1 + field.name.length;
}
cm.replaceRange(value, CodeMirror.Pos(n, index), CodeMirror.Pos(n, index + current.length), '+insert');
this.setState({
value : e.target.value,
});
},
render : function() {
const { value, id } = this.state;
const { field, text, def } = this.props;
const style = STYLE_FN(value);
const pattern = PATTERNS.field[field.type](field.name);
const [_, label, __] = text.match(pattern) ?? [null, undefined, ''];
let className = 'widget-field';
if(!label) {
className += ' suggested';
}
if(def) {
className += ' default';
}
return <React.Fragment>
<div className={className}>
<label htmlFor={id}>{field.name}:</label>
<input id={id} type='text' value={value}
style={style}
ref={this.fieldRef[id]}
onChange={this.onChange}
onFocus={this.setFocus}
onBlur={this.setFocus}
onKeyDown={this.keyDown}/>
</div>
</React.Fragment>;
}
});
module.exports = Text;

View File

@@ -0,0 +1,37 @@
.widget-field {
display: inline-block;
flex: 0 0 auto;
background-color: #ddd;
border-radius: 10px;
padding: 4px 2px;
>label {
display: inline;
width: 50px;
margin: 0 0;
}
>input {
background-color: #ddd;
border: none;
}
>.hints {
position: relative;
left: 30px;
max-height: 100px;
overflow-y: scroll;
background-color: white;
>.hint {
margin: 0 0;
padding: 2px;
cursor: default;
&:hover,
&.active {
background-color: rgba(0, 0, 0, 0.1);
}
}
}
}

View File

@@ -0,0 +1,188 @@
const React = require('react');
const ReactDOMClient = require('react-dom/client');
const { PATTERNS, FIELD_TYPE, HINT_TYPE, UNITS } = require('./widget-elements/constants');
require('./widget-elements/hints/hints.jsx');
const { Text, Checkbox, ImageSelector, ColorSelector } = require('./widget-elements');
const CodeMirror = require('../code-mirror.js');
// See https://codemirror.net/5/addon/hint/css-hint.js for code reference
const pseudoClasses = { 'active' : 1, 'after' : 1, 'before' : 1, 'checked' : 1, 'default' : 1,
'disabled' : 1, 'empty' : 1, 'enabled' : 1, 'first-child' : 1, 'first-letter' : 1,
'first-line' : 1, 'first-of-type' : 1, 'focus' : 1, 'hover' : 1, 'in-range' : 1,
'indeterminate' : 1, 'invalid' : 1, 'lang' : 1, 'last-child' : 1, 'last-of-type' : 1,
'link' : 1, 'not' : 1, 'nth-child' : 1, 'nth-last-child' : 1, 'nth-last-of-type' : 1,
'nth-of-type' : 1, 'only-of-type' : 1, 'only-child' : 1, 'optional' : 1, 'out-of-range' : 1,
'placeholder' : 1, 'read-only' : 1, 'read-write' : 1, 'required' : 1, 'root' : 1,
'selection' : 1, 'target' : 1, 'valid' : 1, 'visited' : 1
};
const genKey = (...args)=>args.join('-');
module.exports = function(widgets, cm, setHints) {
const roots = {};
const spec = CodeMirror.resolveMode('text/css');
const headless = CodeMirror(()=>{});
const makeTempCSSDoc = (value)=>CodeMirror.Doc(`.selector {\n${value}\n}`, 'text/css');
// See https://codemirror.net/5/addon/hint/css-hint.js for code reference
const getStyleHints = (field, value)=>{
const tempDoc = makeTempCSSDoc(`${field.name}:${value?.replaceAll(`'"`, '') ?? ''}`);
headless.swapDoc(tempDoc);
const pos = CodeMirror.Pos(1, field.name.length + 1 + (value?.length ?? 0), false);
const token = headless.getTokenAt(pos);
const inner = CodeMirror.innerMode(tempDoc.getMode(), token?.state);
if(inner.mode.name !== 'css') return;
if(token.type === 'keyword' && '!important'.indexOf(token.string) === 0)
return { list : ['!important'], from : CodeMirror.Pos(pos.line, token.start),
to : CodeMirror.Pos(pos.line, token.end) };
let start = token.start, end = pos.ch, word = token.string.slice(0, end - start);
if(/[^\w$_-]/.test(word)) {
word = ''; start = end = pos.ch;
}
let result = [];
const add = (keywords)=>{
for (const name in keywords)
if(!word || name.lastIndexOf(word, 0) === 0)
result.push(name);
};
const st = inner.state.state;
if(st === 'pseudo' || token.type === 'variable-3') {
add(pseudoClasses);
} else if(st === 'block' || st === 'maybeprop') {
add(spec.propertyKeywords);
} else if(st === 'prop' || st === 'parens' || st === 'at' || st === 'params') {
add(spec.valueKeywords);
add(spec.colorKeywords);
} else if(st === 'media' || st === 'media_parens') {
add(spec.mediaTypes);
add(spec.mediaFeatures);
}
result = result.map((h)=>({ hint: h, type: HINT_TYPE.VALUE }))
.filter((h)=>CSS.supports(field.name, h.hint) && h.hint.includes(value ?? ''));
const numberSuffix = word.slice(-4).replaceAll(/\d/g, '');
if(token.type === 'number' && !UNITS.includes(numberSuffix)) {
result.push(...UNITS
.filter((u)=>u.includes(numberSuffix) && CSS.supports(field.name, `${value.replaceAll(/\D/g, '') ?? ''}${u}`))
.map((u)=>({ hint: u, type: HINT_TYPE.NUMBER_SUFFIX }))
);
}
return result;
};
const widgetOptions = widgets.map((widget)=>({
name : widget.name,
pattern : PATTERNS.snippet[widget.type](widget.name),
renderWidget : (n, node)=>{
roots[n] = roots[n] ?? {};
const parent = document.createElement('div');
const id = `${widget.name}-${n}`;
parent.id = id;
const textFieldNames = (widget.fields || []).filter((f)=>f.type === FIELD_TYPE.TEXT || f.type === FIELD_TYPE.COLOR_SELECTOR).map((f)=>f.name);
const { text } = cm.lineInfo(n);
const fields = (widget.fields || []).map((field)=>{
const key = genKey(widget.name, n, field.name);
if(field.type === FIELD_TYPE.CHECKBOX) {
return <Checkbox key={key} cm={cm} n={n} prefix={widget.name} value={field.name} def={true}/>;
} else if(field.type === FIELD_TYPE.TEXT) {
return <Text key={key} field={field} cm={cm} n={n} text={text} setHints={(f, h)=>setHints(h, f)} getStyleHints={getStyleHints} def={true} snippetType={widget.type}/>;
} else if(field.type === FIELD_TYPE.IMAGE_SELECTOR) {
return <ImageSelector key={key} field={field} cm={cm} n={n}/>;
} else if(field.type === FIELD_TYPE.COLOR_SELECTOR) {
return <ColorSelector key={key} field={field} cm={cm} n={n} text={text} def={true} snippetType={widget.type}/>;
} else {
return null;
}
}).filter(Boolean);
const styles = [...text.matchAll(PATTERNS.collectStyles)].map(([_, style])=>{
if(textFieldNames.includes(style)) return false;
const field = {
name : style,
type : FIELD_TYPE.TEXT,
increment : 5,
hints : true,
};
const key = genKey(widget.name, n, style);
if(style.includes('color')) {
return <ColorSelector key={key} field={field} cm={cm} n={n} text={text} snippetType={widget.type}/>;
}
return <Text key={key} field={field} cm={cm} n={n} text={text} setHints={(f, h)=>setHints(h, f)} getStyleHints={getStyleHints} snippetType={widget.type}/>;
}).filter(Boolean);
const root = roots[n][id] ?? ReactDOMClient.createRoot(node || parent);
root.render(<React.Fragment>
{fields}
{styles}
</React.Fragment>);
roots[n][id] = root;
return node || parent;
}
}));
const updateLineWidgets = (n)=>{
const { text, widgets } = cm.lineInfo(n);
const widgetOption = widgetOptions.find((option)=>!!text.match(option.pattern));
if(!widgetOption) return;
if(!!widgets) {
for (const widget of widgets) {
widgetOption.renderWidget(n, widget.node);
}
} else {
return cm.addLineWidget(n, widgetOption.renderWidget(n), {
above : false,
coverGutter : false,
noHScroll : true,
className : `snippet-options-widget ${widgetOption.name}-widget ${widgetOption.name}-widget-${n}`
});
}
};
return {
roots,
removeLineWidget : (n, widget)=>{
roots[n][widget.node.id]?.unmount();
delete roots[n][widget.node.id];
widget?.clear();
},
updateLineWidgets,
updateAllLineWidgets : ()=>{
for (let i = 0; i < cm.lineCount(); i++) {
const { widgets } = cm.lineInfo(i);
if(!!widgets) {
updateLineWidgets(i);
}
}
},
updateWidgetGutter : ()=>{
cm.operation(()=>{
for (let i = 0; i < cm.lineCount(); i++) {
const { text, widgets } = cm.lineInfo(i);
if(widgetOptions.some((option)=>text.match(option.pattern))) {
if(widgets) {
continue;
}
const optionsMarker = document.createElement('div');
optionsMarker.style.color = '#822';
optionsMarker.style.cursor = 'pointer';
optionsMarker.innerHTML = '●';
cm.setGutterMarker(i, 'widget-gutter', optionsMarker);
} else {
cm.setGutterMarker(i, 'widget-gutter', null);
}
}
});
}
};
};

View File

@@ -4,48 +4,7 @@ const Marked = require('marked');
const MarkedExtendedTables = require('marked-extended-tables');
const { markedSmartypantsLite: MarkedSmartypantsLite } = require('marked-smartypants-lite');
const { gfmHeadingId: MarkedGFMHeadingId } = require('marked-gfm-heading-id');
const { markedEmoji: MarkedEmojis } = require('marked-emoji');
//Icon fonts included so they can appear in emoji autosuggest dropdown
const diceFont = require('../../themes/fonts/iconFonts/diceFont.js');
const elderberryInn = require('../../themes/fonts/iconFonts/elderberryInn.js');
const fontAwesome = require('../../themes/fonts/iconFonts/fontAwesome.js');
const gameIcons = require('../../themes/fonts/iconFonts/gameIcons.js');
const MathParser = require('expr-eval').Parser;
const renderer = new Marked.Renderer();
const tokenizer = new Marked.Tokenizer();
//Limit math features to simple items
const mathParser = new MathParser({
operators : {
// These default to true, but are included to be explicit
add : true,
subtract : true,
multiply : true,
divide : true,
power : true,
round : true,
floor : true,
ceil : true,
sin : false, cos : false, tan : false, asin : false, acos : false,
atan : false, sinh : false, cosh : false, tanh : false, asinh : false,
acosh : false, atanh : false, sqrt : false, cbrt : false, log : false,
log2 : false, ln : false, lg : false, log10 : false, expm1 : false,
log1p : false, abs : false, trunc : false, join : false, sum : false,
'-' : false, '+' : false, exp : false, not : false, length : false,
'!' : false, sign : false, random : false, fac : false, min : false,
max : false, hypot : false, pyt : false, pow : false, atan2 : false,
'if' : false, gamma : false, roundTo : false, map : false, fold : false,
filter : false, indexOf : false,
remainder : false, factorial : false,
comparison : false, concatenate : false,
logical : false, assignment : false,
array : false, fndef : false
}
});
//Processes the markdown within an HTML block if it's just a class-wrapper
renderer.html = function (html) {
@@ -58,7 +17,7 @@ renderer.html = function (html) {
return html;
};
// Don't wrap {{ Spans alone on a line, or {{ Divs in <p> tags
// Don't wrap {{ Divs or {{ empty Spans in <p> tags
renderer.paragraph = function(text){
let match;
if(text.startsWith('<div') || text.startsWith('</div'))
@@ -69,51 +28,24 @@ renderer.paragraph = function(text){
return `<p>${text}</p>\n`;
};
//Fix local links in the Preview iFrame to link inside the frame
renderer.link = function (href, title, text) {
let self = false;
if(href[0] == '#') {
self = true;
}
href = cleanUrl(this.options.sanitize, this.options.baseUrl, href);
if(href === null) {
return text;
}
let out = `<a href="${escape(href)}"`;
if(title) {
out += ` title="${title}"`;
}
if(self) {
out += ' target="_self"';
}
out += `>${text}</a>`;
return out;
};
// Disable default reflink behavior, as it steps on our variables extension
tokenizer.def = function () {
return undefined;
};
const mustacheSpans = {
name : 'mustacheSpans',
level : 'inline', // Is this a block-level or inline-level tokenizer?
start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
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);
if(match) {
//Find closing delimiter
let blockCount = 0;
let tags = {};
let tags = '';
let endTags = 0;
let endToken = 0;
let delim;
while (delim = inlineRegex.exec(match[0])) {
if(_.isEmpty(tags)) {
tags = processStyleTags(delim[0].substring(2));
if(!tags) {
tags = ` ${processStyleTags(delim[0].substring(2))}`;
endTags = delim[0].length;
}
if(delim[0].startsWith('{{')) {
@@ -142,14 +74,7 @@ const mustacheSpans = {
}
},
renderer(token) {
const tags = token.tags;
tags.classes = ['inline-block', tags.classes].join(' ').trim();
return `<span` +
`${tags.classes ? ` class="${tags.classes}"` : ''}` +
`${tags.id ? ` id="${tags.id}"` : ''}` +
`${tags.styles ? ` style="${tags.styles}"` : ''}` +
`${tags.attributes ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}` : ''}` +
`>${this.parser.parseInline(token.tokens)}</span>`; // parseInline to turn child tokens into HTML
return `<span class="inline-block${token.tags}>${this.parser.parseInline(token.tokens)}</span>`; // parseInline to turn child tokens into HTML
}
};
@@ -159,19 +84,19 @@ const mustacheDivs = {
start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
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);
if(match) {
//Find closing delimiter
let blockCount = 0;
let tags = {};
let tags = '';
let endTags = 0;
let endToken = 0;
let delim;
while (delim = blockRegex.exec(match[0])?.[0].trim()) {
if(_.isEmpty(tags)) {
tags = processStyleTags(delim.substring(2));
endTags = delim.length + src.indexOf(delim);
if(!tags) {
tags = ` ${processStyleTags(delim.substring(2))}`;
endTags = delim.length;
}
if(delim.startsWith('{{')) {
blockCount++;
@@ -198,14 +123,7 @@ const mustacheDivs = {
}
},
renderer(token) {
const tags = token.tags;
tags.classes = ['block', tags.classes].join(' ').trim();
return `<div` +
`${tags.classes ? ` class="${tags.classes}"` : ''}` +
`${tags.id ? ` id="${tags.id}"` : ''}` +
`${tags.styles ? ` style="${tags.styles}"` : ''}` +
`${tags.attributes ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}` : ''}` +
`>${this.parser.parse(token.tokens)}</div>`; // parse to turn child tokens into HTML
return `<div class="block${token.tags}>${this.parser.parse(token.tokens)}</div>`; // parseInline to turn child tokens into HTML
}
};
@@ -214,46 +132,30 @@ const mustacheInjectInline = {
level : 'inline',
start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/g;
const inlineRegex = /^ *{(?=((?::(?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':{}\s]*)*))\1}/g;
const match = inlineRegex.exec(src);
if(match) {
const lastToken = tokens[tokens.length - 1];
if(!lastToken || lastToken.type == 'mustacheInjectInline')
return false;
const tags = processStyleTags(match[1]);
const tags = ` ${processStyleTags(match[1])}`;
lastToken.originalType = lastToken.type;
lastToken.type = 'mustacheInjectInline';
lastToken.injectedTags = tags;
lastToken.tags = tags;
return {
type : 'mustacheInjectInline', // Should match "name" above
type : 'text', // Should match "name" above
raw : match[0], // Text to consume from the source
text : ''
};
}
},
renderer(token) {
if(!token.originalType){
return;
}
token.type = token.originalType;
const text = this.parser.parseInline([token]);
const originalTags = extractHTMLStyleTags(text);
const injectedTags = token.injectedTags;
const tags = {
id : injectedTags.id || originalTags.id || null,
classes : [originalTags.classes, injectedTags.classes].join(' ').trim() || null,
styles : [originalTags.styles, injectedTags.styles].join(' ').trim() || null,
attributes : Object.assign(originalTags.attributes ?? {}, injectedTags.attributes ?? {})
};
const openingTag = /(<[^\s<>]+)[^\n<>]*(>.*)/s.exec(text);
const openingTag = /(<[^\s<>]+)([^\n<>]*>.*)/s.exec(text);
if(openingTag) {
return `${openingTag[1]}` +
`${tags.classes ? ` class="${tags.classes}"` : ''}` +
`${tags.id ? ` id="${tags.id}"` : ''}` +
`${tags.styles ? ` style="${tags.styles}"` : ''}` +
`${!_.isEmpty(tags.attributes) ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}` : ''}` +
`${openingTag[2]}`; // parse to turn child tokens into HTML
return `${openingTag[1]} class="${token.tags}${openingTag[2]}`;
}
return text;
}
@@ -265,7 +167,7 @@ const mustacheInjectBlock = {
level : 'block',
start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/ym;
const inlineRegex = /^ *{(?=((?::(?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':{}\s]*)*))\1}/ym;
const match = inlineRegex.exec(src);
if(match) {
const lastToken = tokens[tokens.length - 1];
@@ -273,7 +175,7 @@ const mustacheInjectBlock = {
return false;
lastToken.originalType = 'mustacheInjectBlock';
lastToken.injectedTags = processStyleTags(match[1]);
lastToken.tags = ` ${processStyleTags(match[1])}`;
return {
type : 'mustacheInjectBlock', // Should match "name" above
raw : match[0], // Text to consume from the source
@@ -287,22 +189,9 @@ const mustacheInjectBlock = {
}
token.type = token.originalType;
const text = this.parser.parse([token]);
const originalTags = extractHTMLStyleTags(text);
const injectedTags = token.injectedTags;
const tags = {
id : injectedTags.id || originalTags.id || null,
classes : [originalTags.classes, injectedTags.classes].join(' ').trim() || null,
styles : [originalTags.styles, injectedTags.styles].join(' ').trim() || null,
attributes : Object.assign(originalTags.attributes ?? {}, injectedTags.attributes ?? {})
};
const openingTag = /(<[^\s<>]+)[^\n<>]*(>.*)/s.exec(text);
const openingTag = /(<[^\s<>]+)([^\n<>]*>.*)/s.exec(text);
if(openingTag) {
return `${openingTag[1]}` +
`${tags.classes ? ` class="${tags.classes}"` : ''}` +
`${tags.id ? ` id="${tags.id}"` : ''}` +
`${tags.styles ? ` style="${tags.styles}"` : ''}` +
`${!_.isEmpty(tags.attributes) ? ` ${Object.entries(tags.attributes).map(([key, value])=>`${key}="${value}"`).join(' ')}` : ''}` +
`${openingTag[2]}`; // parse to turn child tokens into HTML
return `${openingTag[1]} class="${token.tags}${openingTag[2]}`;
}
return text;
}
@@ -317,62 +206,25 @@ const mustacheInjectBlock = {
}
};
const superSubScripts = {
name : 'superSubScript',
level : 'inline',
start(src) { return src.match(/\^/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const superRegex = /^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/m;
const subRegex = /^\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/m;
let isSuper = false;
let match = subRegex.exec(src);
if(!match){
match = superRegex.exec(src);
if(match)
isSuper = true;
}
if(match?.length) {
return {
type : 'superSubScript', // Should match "name" above
raw : match[0], // Text to consume from the source
tag : isSuper ? 'sup' : 'sub',
tokens : this.lexer.inlineTokens(match[1])
};
}
},
renderer(token) {
return `<${token.tag}>${this.parser.parseInline(token.tokens)}</${token.tag}>`;
}
};
const definitionListsSingleLine = {
name : 'definitionListsSingleLine',
const definitionLists = {
name : 'definitionLists',
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(/^.*?::.*/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const regex = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym;
let match;
let endIndex = 0;
const definitions = [];
while (match = regex.exec(src)) {
const originalLine = match[0]; // This line and below to handle conflict with emojis
let firstLine = originalLine; // Remove in V4 when definitionListsInline updated to
this.lexer.inlineTokens(firstLine.trim()) // require spaces around `::`
.filter((t)=>t.type == 'emoji')
.map((emoji)=>firstLine = firstLine.replace(emoji.raw, 'x'.repeat(emoji.raw.length)));
const newMatch = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym.exec(firstLine);
if(newMatch) {
definitions.push({
dt : this.lexer.inlineTokens(originalLine.slice(0, newMatch[1].length).trim()),
dd : this.lexer.inlineTokens(originalLine.slice(newMatch[1].length + 2).trim())
});
} // End of emoji hack.
definitions.push({
dt : this.lexer.inlineTokens(match[1].trim()),
dd : this.lexer.inlineTokens(match[2].trim())
});
endIndex = regex.lastIndex;
}
if(definitions.length) {
return {
type : 'definitionListsSingleLine',
type : 'definitionLists',
raw : src.slice(0, endIndex),
definitions
};
@@ -386,320 +238,32 @@ const definitionListsSingleLine = {
}
};
const definitionListsMultiLine = {
name : 'definitionListsMultiLine',
level : 'block',
start(src) { return src.match(/\n[^\n]*\n::/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const regex = /(\n?\n?(?!::)[^\n]+?(?=\n::))|\n::(.(?:.|\n)*?(?=(?:\n::)|(?:\n\n)|$))/y;
let match;
let endIndex = 0;
const definitions = [];
while (match = regex.exec(src)) {
if(match[1]) {
if(this.lexer.blockTokens(match[1].trim())[0]?.type !== 'paragraph') // DT must not be another block-level token besides <p>
break;
definitions.push({
dt : this.lexer.inlineTokens(match[1].trim()),
dds : []
});
}
if(match[2] && definitions.length) {
definitions[definitions.length - 1].dds.push(
this.lexer.inlineTokens(match[2].trim().replace(/\s/g, ' '))
);
}
endIndex = regex.lastIndex;
}
if(definitions.length) {
return {
type : 'definitionListsMultiLine',
raw : src.slice(0, endIndex),
definitions
};
}
},
renderer(token) {
let returnVal = `<dl>`;
token.definitions.forEach((def)=>{
const dds = def.dds.map((s)=>{
return `\n<dd>${this.parser.parseInline(s).trim()}</dd>`;
}).join('');
returnVal += `<dt>${this.parser.parseInline(def.dt)}</dt>${dds}\n`;
});
returnVal = returnVal.trim();
return `${returnVal}</dl>`;
}
};
//v=====--------------------< Variable Handling >-------------------=====v// 242 lines
const replaceVar = function(input, hoist=false, allowUnresolved=false) {
const regex = /([!$]?)\[((?!\s*\])(?:\\.|[^\[\]\\])+)\]/g;
const match = regex.exec(input);
const prefix = match[1];
const label = match[2];
//v=====--------------------< HANDLE MATH >-------------------=====v//
const mathRegex = /[a-z]+\(|[+\-*/^()]/g;
const matches = label.split(mathRegex);
const mathVars = matches.filter((match)=>isNaN(match))?.map((s)=>s.trim()); // Capture any variable names
let replacedLabel = label;
if(prefix[0] == '$' && mathVars?.[0] !== label.trim()) {// If there was mathy stuff not captured, let's do math!
mathVars?.forEach((variable)=>{
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
replacedLabel = replacedLabel.replaceAll(variable, foundVar.content);
});
try {
return mathParser.evaluate(replacedLabel);
} catch (error) {
return undefined; // Return undefined if invalid math result
}
}
//^=====--------------------< HANDLE MATH >-------------------=====^//
const foundVar = lookupVar(label, globalPageNumber, hoist);
if(!foundVar || (!foundVar.resolved && !allowUnresolved))
return undefined; // Return undefined if not found, or parially-resolved vars are not allowed
// url or <url> "title" or 'title' or (title)
const linkRegex = /^([^<\s][^\s]*|<.*?>)(?: ("(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\((?:\\\(|\\\)|[^()])*\)))?$/m;
const linkMatch = linkRegex.exec(foundVar.content);
const href = linkMatch ? linkMatch[1] : null; //TODO: TRIM OFF < > IF PRESENT
const title = linkMatch ? linkMatch[2]?.slice(1, -1) : null;
if(!prefix[0] && href) // Link
return `[${label}](${href}${title ? ` "${title}"` : ''})`;
if(prefix[0] == '!' && href) // Image
return `![${label}](${href} ${title ? ` "${title}"` : ''})`;
if(prefix[0] == '$') // Variable
return foundVar.content;
};
const lookupVar = function(label, index, hoist=false) {
while (index >= 0) {
if(globalVarsList[index]?.[label] !== undefined)
return globalVarsList[index][label];
index--;
}
if(hoist) { //If normal lookup failed, attempt hoisting
index = Object.keys(globalVarsList).length; // Move index to start from last page
while (index >= 0) {
if(globalVarsList[index]?.[label] !== undefined)
return globalVarsList[index][label];
index--;
}
}
return undefined;
};
const processVariableQueue = function() {
let resolvedOne = true;
let finalLoop = false;
while (resolvedOne || finalLoop) { // Loop through queue until no more variable calls can be resolved
resolvedOne = false;
for (const item of varsQueue) {
if(item.type == 'text')
continue;
if(item.type == 'varDefBlock') {
const regex = /[!$]?\[((?!\s*\])(?:\\.|[^\[\]\\])+)\]/g;
let match;
let resolved = true;
let tempContent = item.content;
while (match = regex.exec(item.content)) { // regex to find variable calls
const value = replaceVar(match[0], true);
if(value == undefined)
resolved = false;
else
tempContent = tempContent.replaceAll(match[0], value);
}
if(resolved == true || item.content != tempContent) {
resolvedOne = true;
item.content = tempContent;
}
globalVarsList[globalPageNumber][item.varName] = {
content : item.content,
resolved : resolved
};
if(resolved)
item.type = 'resolved';
}
if(item.type == 'varCallBlock' || item.type == 'varCallInline') {
const value = replaceVar(item.content, true, finalLoop); // final loop will just use the best value so far
if(value == undefined)
continue;
resolvedOne = true;
item.content = value;
item.type = 'text';
}
}
varsQueue = varsQueue.filter((item)=>item.type !== 'resolved'); // Remove any fully-resolved variable definitions
if(finalLoop)
break;
if(!resolvedOne)
finalLoop = true;
}
varsQueue = varsQueue.filter((item)=>item.type !== 'varDefBlock');
};
function MarkedVariables() {
return {
hooks : {
preprocess(src) {
const codeBlockSkip = /^(?: {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+|^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})(?:[^\n]*)(?:\n|$)(?:|(?:[\s\S]*?)(?:\n|$))(?: {0,3}\2[~`]* *(?=\n|$))|`[^`]*?`/;
const blockDefRegex = /^[!$]?\[((?!\s*\])(?:\\.|[^\[\]\\])+)\]:(?!\() *((?:\n? *[^\s].*)+)(?=\n+|$)/; //Matches 3, [4]:5
const blockCallRegex = /^[!$]?\[((?!\s*\])(?:\\.|[^\[\]\\])+)\](?=\n|$)/; //Matches 6, [7]
const inlineDefRegex = /([!$]?\[((?!\s*\])(?:\\.|[^\[\]\\])+)\])\(([^\n]+)\)/; //Matches 8, 9[10](11)
const inlineCallRegex = /[!$]?\[((?!\s*\])(?:\\.|[^\[\]\\])+)\](?!\()/; //Matches 12, [13]
// Combine regexes and wrap in parens like so: (regex1)|(regex2)|(regex3)|(regex4)
const combinedRegex = new RegExp([codeBlockSkip, blockDefRegex, blockCallRegex, inlineDefRegex, inlineCallRegex].map((s)=>`(${s.source})`).join('|'), 'gm');
let lastIndex = 0;
let match;
while ((match = combinedRegex.exec(src)) !== null) {
// Format any matches into tokens and store
if(match.index > lastIndex) { // Any non-variable stuff
varsQueue.push(
{ type : 'text',
varName : null,
content : src.slice(lastIndex, match.index)
});
}
if(match[1]) {
varsQueue.push(
{ type : 'text',
varName : null,
content : match[0]
});
}
if(match[3]) { // Block Definition
const label = match[4] ? match[4].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
const content = match[5] ? match[5].trim().replace(/[ \t]+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
varsQueue.push(
{ type : 'varDefBlock',
varName : label,
content : content
});
}
if(match[6]) { // Block Call
const label = match[7] ? match[7].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
varsQueue.push(
{ type : 'varCallBlock',
varName : label,
content : match[0]
});
}
if(match[8]) { // Inline Definition
const label = match[10] ? match[10].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
let content = match[11] ? match[11].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
// In case of nested (), find the correct matching end )
let level = 0;
let i;
for (i = 0; i < content.length; i++) {
if(content[i] === '\\') {
i++;
} else if(content[i] === '(') {
level++;
} else if(content[i] === ')') {
level--;
if(level < 0)
break;
}
}
if(i > -1) {
combinedRegex.lastIndex = combinedRegex.lastIndex - (content.length - i);
content = content.slice(0, i).trim().replace(/\s+/g, ' ');
}
varsQueue.push(
{ type : 'varDefBlock',
varName : label,
content : content
});
varsQueue.push(
{ type : 'varCallInline',
varName : label,
content : match[9]
});
}
if(match[12]) { // Inline Call
const label = match[13] ? match[13].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
varsQueue.push(
{ type : 'varCallInline',
varName : label,
content : match[0]
});
}
lastIndex = combinedRegex.lastIndex;
}
if(lastIndex < src.length) {
varsQueue.push(
{ type : 'text',
varName : null,
content : src.slice(lastIndex)
});
}
processVariableQueue();
const output = varsQueue.map((item)=>item.content).join('');
varsQueue = []; // Must clear varsQueue because custom HTML renderer uses Marked.parse which will preprocess again without clearing the array
return output;
}
}
};
};
//^=====--------------------< Variable Handling >-------------------=====^//
// Emoji options
// To add more icon fonts, need to do these things
// 1) Add the font file as .woff2 to themes/fonts/iconFonts folder
// 2) Create a .less file mapping CSS class names to the font character
// 3) Create a .js file mapping Autosuggest names to CSS class names
// 4) Import the .less file into shared/naturalcrit/codeEditor/codeEditor.less
// 5) Import the .less file into themes/V3/blank.style.less
// 6) Import the .js file to shared/naturalcrit/codeEditor/autocompleteEmoji.js and add to `emojis` object
// 7) Import the .js file here to markdown.js, and add to `emojis` object below
const MarkedEmojiOptions = {
emojis : {
...diceFont,
...elderberryInn,
...fontAwesome,
...gameIcons,
},
renderer : (token)=>`<i class="${token.emoji}"></i>`
};
Marked.use(MarkedVariables());
Marked.use({ extensions: [definitionListsMultiLine, definitionListsSingleLine, superSubScripts, mustacheSpans, mustacheDivs, mustacheInjectInline] });
Marked.use({ extensions: [mustacheSpans, mustacheDivs, mustacheInjectInline, definitionLists] });
Marked.use(mustacheInjectBlock);
Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
Marked.use(MarkedExtendedTables(), MarkedGFMHeadingId(), MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
Marked.use({ renderer: renderer, mangle: false });
Marked.use(MarkedExtendedTables(), MarkedGFMHeadingId(), MarkedSmartypantsLite());
//Fix local links in the Preview iFrame to link inside the frame
renderer.link = function (href, title, text) {
let self = false;
if(href[0] == '#') {
self = true;
}
href = cleanUrl(this.options.sanitize, this.options.baseUrl, href);
if(href === null) {
return text;
}
let out = `<a href="${escape(href)}"`;
if(title) {
out += ` title="${title}"`;
}
if(self) {
out += ' target="_self"';
}
out += `>${text}</a>`;
return out;
};
const nonWordAndColonTest = /[^\w:]/g;
const cleanUrl = function (sanitize, base, href) {
@@ -762,77 +326,24 @@ const voidTags = new Set([
]);
const processStyleTags = (string)=>{
//split tags up. quotes can only occur right after : or =.
//split tags up. quotes can only occur right after colons.
//TODO: can we simplify to just split on commas?
const tags = string.match(/(?:[^, ":=]+|[:=](?:"[^"]*"|))+/g);
const tags = string.match(/(?:[^, ":]+|:(?:"[^"]*"|))+/g);
const id = _.remove(tags, (tag)=>tag.startsWith('#')).map((tag)=>tag.slice(1))[0] || null;
const classes = _.remove(tags, (tag)=>(!tag.includes(':')) && (!tag.includes('='))).join(' ') || null;
const attributes = _.remove(tags, (tag)=>(tag.includes('='))).map((tag)=>tag.replace(/="?([^"]*)"?/g, '="$1"'))
?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="'))
.reduce((obj, attr)=>{
const index = attr.indexOf('=');
let [key, value] = [attr.substring(0, index), attr.substring(index + 1)];
value = value.replace(/"/g, '');
obj[key] = value;
return obj;
}, {}) || null;
const styles = tags?.length ? tags.map((tag)=>tag.replace(/:"?([^"]*)"?/g, ':$1;').trim()).join(' ') : null;
if(!tags) return '"';
return {
id : id,
classes : classes,
styles : styles,
attributes : _.isEmpty(attributes) ? null : attributes
};
const id = _.remove(tags, (tag)=>tag.startsWith('#')).map((tag)=>tag.slice(1))[0];
const classes = _.remove(tags, (tag)=>!tag.includes(':'));
const styles = tags.map((tag)=>tag.replace(/:"?([^"]*)"?/g, ':$1;'));
return `${classes.join(' ')}" ${id ? `id="${id}"` : ''} ${styles.length ? `style="${styles.join(' ')}"` : ''}`;
};
//Given a string representing an HTML element, extract all of its properties (id, class, style, and other attributes)
const extractHTMLStyleTags = (htmlString)=>{
const firstElementOnly = htmlString.split('>')[0];
const id = firstElementOnly.match(/id="([^"]*)"/)?.[1] || null;
const classes = firstElementOnly.match(/class="([^"]*)"/)?.[1] || null;
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="'))
.reduce((obj, attr)=>{
const index = attr.indexOf('=');
let [key, value] = [attr.substring(0, index), attr.substring(index + 1)];
value = value.replace(/"/g, '');
obj[key] = value;
return obj;
}, {}) || null;
return {
id : id,
classes : classes,
styles : styles,
attributes : _.isEmpty(attributes) ? null : attributes
};
};
const globalVarsList = {};
let varsQueue = [];
let globalPageNumber = 0;
module.exports = {
marked : Marked,
render : (rawBrewText, pageNumber=1)=>{
globalVarsList[pageNumber] = {}; //Reset global links for current page, to ensure values are parsed in order
varsQueue = []; //Could move into MarkedVariables()
globalPageNumber = pageNumber;
render : (rawBrewText)=>{
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;
rawBrewText = opts.hooks.preprocess(rawBrewText);
const tokens = Marked.lexer(rawBrewText, opts);
Marked.walkTokens(tokens, opts.walkTokens);
const html = Marked.parser(tokens, opts);
return opts.hooks.postprocess(html);
return Marked.parse(rawBrewText);
},
validate : (rawBrewText)=>{

View File

@@ -1,4 +1,4 @@
require('client/homebrew/navbar/navbar.less');
require('./nav.less');
const React = require('react');
const { useState, useRef, useEffect } = React;
const createClass = require('create-react-class');
@@ -47,8 +47,8 @@ const Nav = {
color : null
};
},
handleClick : function(e){
this.props.onClick(e);
handleClick : function(){
this.props.onClick();
},
render : function(){
const classes = cx('navItem', this.props.color, this.props.className);
@@ -104,7 +104,7 @@ const Nav = {
});
return (
<div className={`navDropdownContainer ${props.className ?? ''}`}
<div className={`navDropdownContainer ${props.className}`}
ref={myRef}
onMouseEnter = { props.trigger.includes('hover') ? ()=>handleDropdown(true) : undefined }
onMouseLeave = { props.trigger.includes('hover') ? ()=>handleDropdown(false) : undefined }

View File

@@ -0,0 +1,97 @@
@import '../styles/colors';
@keyframes glideDropDown {
0% {transform : translate(0px, -100%);
opacity : 0;
background-color: #333;}
100% {transform : translate(0px, 0px);
opacity : 1;
background-color: #333;}
}
nav{
background-color : #333;
.navContent{
position : relative;
display : flex;
justify-content : space-between;
z-index : 2;
}
.navSection{
display : flex;
align-items : center;
}
.navLogo{
display : block;
margin-top : 0px;
margin-right : 8px;
margin-left : 8px;
color : white;
text-decoration : none;
&:hover{
.name{ color : @orange; }
svg{ fill : @orange }
}
svg{
height : 13px;
margin-right : 0.2em;
cursor : pointer;
fill : white;
}
span.name{
font-family : 'CodeLight';
font-size : 15px;
span.crit{
font-family : 'CodeBold';
}
small{
font-family : 'Open Sans';
font-size : 0.3em;
font-weight : 800;
text-transform : uppercase;
}
}
}
.navItem{
#backgroundColorsHover;
.animate(background-color);
padding : 8px 12px;
cursor : pointer;
background-color : #333;
font-size : 10px;
font-weight : 800;
color : white;
text-decoration : none;
text-transform : uppercase;
line-height : 13px;
i{
margin-left : 5px;
font-size : 13px;
float : right;
}
}
.navSection:last-child .navItem{
border-left : 1px solid #666;
}
.navDropdownContainer{
position: relative;
.navDropdown {
position : absolute;
top : 28px;
left : 0px;
z-index : 10000;
width : 100%;
overflow : hidden auto;
max-height : calc(100vh - 28px);
.navItem{
animation-name: glideDropDown;
animation-duration: 0.4s;
position : relative;
display : block;
width : 100%;
vertical-align : middle;
padding : 8px 5px;
border : 1px solid #888;
border-bottom : 0;
}
}
}
}

View File

@@ -1,6 +1,7 @@
require('./splitPane.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
const SplitPane = createClass({
@@ -23,9 +24,6 @@ const SplitPane = createClass({
};
},
pane1 : React.createRef(null),
pane2 : React.createRef(null),
componentDidMount : function() {
const dividerPos = window.localStorage.getItem(this.props.storageKey);
if(dividerPos){
@@ -63,8 +61,7 @@ const SplitPane = createClass({
return result;
},
handleUp : function(e){
e.preventDefault();
handleUp : function(){
if(this.state.isDragging){
this.props.onDragFinish(this.state.currentDividerPos);
window.localStorage.setItem(this.props.storageKey, this.state.currentDividerPos);
@@ -81,7 +78,6 @@ const SplitPane = createClass({
handleMove : function(e){
if(!this.state.isDragging) return;
e.preventDefault();
const newSize = this.limitPosition(e.pageX);
this.setState({
currentDividerPos : newSize,
@@ -126,7 +122,7 @@ const SplitPane = createClass({
renderDivider : function(){
return <>
{this.renderMoveArrows()}
<div className='divider' onPointerDown={this.handleDown} >
<div className='divider' onMouseDown={this.handleDown} >
<div className='dots'>
<i className='fas fa-circle' />
<i className='fas fa-circle' />
@@ -137,8 +133,9 @@ const SplitPane = createClass({
},
render : function(){
return <div className='splitPane' onPointerMove={this.handleMove} onPointerUp={this.handleUp}>
return <div className='splitPane' onMouseMove={this.handleMove} onMouseUp={this.handleUp}>
<Pane
ref='pane1'
width={this.state.currentDividerPos}
>
{React.cloneElement(this.props.children[0], {
@@ -148,7 +145,7 @@ const SplitPane = createClass({
})}
</Pane>
{this.renderDivider()}
<Pane isDragging={this.state.isDragging}>{this.props.children[1]}</Pane>
<Pane ref='pane2' isDragging={this.state.isDragging}>{this.props.children[1]}</Pane>
</div>;
}
});

View File

@@ -11,7 +11,6 @@
flex : 1;
}
.divider{
touch-action : none;
display : table;
height : 100%;
width : 15px;

View File

@@ -1,91 +0,0 @@
/* eslint-disable max-lines */
const Markdown = require('naturalcrit/markdown.js');
describe('Inline Definition Lists', ()=>{
test('No Term 1 Definition', function() {
const source = ':: My First Definition\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt></dt><dd>My First Definition</dd>\n</dl>');
});
test('Single Definition Term', function() {
const source = 'My term :: My First Definition\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>My term</dt><dd>My First Definition</dd>\n</dl>');
});
test('Multiple Definition Terms', function() {
const source = 'Term 1::Definition of Term 1\nTerm 2::Definition of Term 2\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt><dd>Definition of Term 1</dd>\n<dt>Term 2</dt><dd>Definition of Term 2</dd>\n</dl>');
});
});
describe('Multiline Definition Lists', ()=>{
test('Single Term, Single Definition', function() {
const source = 'Term 1\n::Definition 1\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1</dd></dl>');
});
test('Single Term, Plural Definitions', function() {
const source = 'Term 1\n::Definition 1\n::Definition 2\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl>');
});
test('Multiple Term, Single Definitions', function() {
const source = 'Term 1\n::Definition 1\n\nTerm 2\n::Definition 1\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd></dl>');
});
test('Multiple Term, Plural Definitions', function() {
const source = 'Term 1\n::Definition 1\n::Definition 2\n\nTerm 2\n::Definition 1\n::Definition 2\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl>');
});
test('Single Term, Single multi-line definition', function() {
const source = 'Term 1\n::Definition 1\nand more and\nmore and more\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more and more</dd></dl>');
});
test('Single Term, Plural multi-line definitions', function() {
const source = 'Term 1\n::Definition 1\nand more and more\n::Definition 2\nand more\nand more\n::Definition 3\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more</dd>\n<dd>Definition 2 and more and more</dd>\n<dd>Definition 3</dd></dl>');
});
test('Multiple Term, Single multi-line definition', function() {
const source = 'Term 1\n::Definition 1\nand more and more\n\nTerm 2\n::Definition 1\n::Definition 2\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl>');
});
test('Multiple Term, Single multi-line definition, followed by an inline dl', function() {
const source = 'Term 1\n::Definition 1\nand more and more\n\nTerm 2\n::Definition 1\n::Definition 2\n\n::Inline Definition (no term)';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl><dl><dt></dt><dd>Inline Definition (no term)</dd>\n</dl>');
});
test('Multiple Term, Single multi-line definition, followed by paragraph', function() {
const source = 'Term 1\n::Definition 1\nand more and more\n\nTerm 2\n::Definition 1\n::Definition 2\n\nParagraph';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<dl><dt>Term 1</dt>\n<dd>Definition 1 and more and more</dd>\n<dt>Term 2</dt>\n<dd>Definition 1</dd>\n<dd>Definition 2</dd></dl><p>Paragraph</p>');
});
test('Block Token cannot be the Term of a multi-line definition', function() {
const source = '## Header\n::Definition 1 of a single-line DL\n::Definition 1 of another single-line DL';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<h2 id="header">Header</h2>\n<dl><dt></dt><dd>Definition 1 of a single-line DL</dd>\n<dt></dt><dd>Definition 1 of another single-line DL</dd>\n</dl>');
});
test('Inline DL has priority over Multiline', function() {
const source = 'Term 1 :: Inline definition 1\n:: Inline definition 2 (no DT)';
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>');
});
});

View File

@@ -1,58 +0,0 @@
const Markdown = require('naturalcrit/markdown.js');
const dedent = require('dedent-tabs').default;
// Marked.js adds line returns after closing tags on some default tokens.
// This removes those line returns for comparison sake.
String.prototype.trimReturns = function(){
return this.replace(/\r?\n|\r/g, '');
};
const emoji = 'df_d12_2';
describe(`When emojis/icons are active`, ()=>{
it('when a word is between two colons (:word:), and a matching emoji exists, it is rendered as an emoji', function() {
const source = `:${emoji}:`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><i class="df d12-2"></i></p>`);
});
it('when a word is between two colons (:word:), and no matching emoji exists, it is not parsed', function() {
const source = `:invalid:`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>:invalid:</p>`);
});
it('two valid emojis with no whitespace are prioritized over definition lists', function() {
const source = `:${emoji}::${emoji}:`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><i class="df d12-2"></i><i class="df d12-2"></i></p>`);
});
it('definition lists that are not also part of an emoji can coexist with normal emojis', function() {
const source = `definition :: term ${emoji}::${emoji}:`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>definition</dt><dd>term df_d12_2:<i class="df d12-2"></i></dd></dl>`);
});
it('A valid emoji is compatible with curly injectors', function() {
const source = `:${emoji}:{color:blue,myClass}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><i class="df d12-2 myClass" style="color:blue;"></i></p>`);
});
it('Emojis are not parsed inside of curly span CSS blocks', function() {
const source = `{{color:${emoji} text}}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<span class="inline-block" style="color:df_d12_2;">text</span>`);
});
it('Emojis are not parsed inside of curly div CSS blocks', function() {
const source = dedent`{{color:${emoji}
text
}}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" style="color:df_d12_2;"><p>text</p></div>`);
});
// another test of the editor to confirm an autocomplete menu opens
});

View File

@@ -13,229 +13,209 @@ String.prototype.trimReturns = function(){
// Remove the `.failing()` method once you have fixed the issue.
describe('Inline: When using the Inline syntax {{ }}', ()=>{
it('Renders a mustache span with text only', function() {
it.failing('Renders a mustache span with text only', function() {
const source = '{{ text}}';
const rendered = Markdown.render(source);
// FIXME: adds extra \s after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block">text</span>');
});
it('Renders a mustache span with text only, but with spaces', function() {
it.failing('Renders a mustache span with text only, but with spaces', function() {
const source = '{{ this is a text}}';
const rendered = Markdown.render(source);
// FIXME: adds extra \s after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block">this is a text</span>');
});
it('Renders an empty mustache span', function() {
it.failing('Renders an empty mustache span', function() {
const source = '{{}}';
const rendered = Markdown.render(source);
// FIXME: adds extra \s after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block"></span>');
});
it('Renders a mustache span with just a space', function() {
it.failing('Renders a mustache span with just a space', function() {
const source = '{{ }}';
const rendered = Markdown.render(source);
// FIXME: adds extra \s after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block"></span>');
});
it('Renders a mustache span with a few spaces only', function() {
it.failing('Renders a mustache span with a few spaces only', function() {
const source = '{{ }}';
const rendered = Markdown.render(source);
// FIXME: adds extra \s after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block"></span>');
});
it('Renders a mustache span with text and class', function() {
it.failing('Renders a mustache span with text and class', function() {
const source = '{{my-class text}}';
const rendered = Markdown.render(source);
// FIXME: adds two extra \s before closing `>` in opening tag.
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block my-class">text</span>');
});
it('Renders a mustache span with text and two classes', function() {
it.failing('Renders a mustache span with text and two classes', function() {
const source = '{{my-class,my-class2 text}}';
const rendered = Markdown.render(source);
// FIXME: adds two extra \s before closing `>` in opening tag.
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block my-class my-class2">text</span>');
});
it('Renders a mustache span with text with spaces and class', function() {
it.failing('Renders a mustache span with text with spaces and class', function() {
const source = '{{my-class this is a text}}';
const rendered = Markdown.render(source);
// FIXME: adds two extra \s before closing `>` in opening tag
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block my-class">this is a text</span>');
});
it('Renders a mustache span with text and id', function() {
it.failing('Renders a mustache span with text and id', function() {
const source = '{{#my-span text}}';
const rendered = Markdown.render(source);
// FIXME: adds extra \s before closing `>` in opening tag, and another after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" id="my-span">text</span>');
});
it('Renders a mustache span with text and two ids', function() {
it.failing('Renders a mustache span with text and two ids', function() {
const source = '{{#my-span,#my-favorite-span text}}';
const rendered = Markdown.render(source);
// FIXME: adds extra \s before closing `>` in opening tag, and another after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" id="my-span">text</span>');
});
it('Renders a mustache span with text and css property', function() {
it.failing('Renders a mustache span with text and css property', function() {
const source = '{{color:red text}}';
const rendered = Markdown.render(source);
// FIXME: adds extra \s after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="color:red;">text</span>');
});
it('Renders a mustache span with text and two css properties', function() {
it.failing('Renders a mustache span with text and two css properties', function() {
const source = '{{color:red,padding:5px text}}';
const rendered = Markdown.render(source);
// FIXME: adds extra \s after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="color:red; padding:5px;">text</span>');
});
it('Renders a mustache span with text and css property which contains quotes', function() {
it.failing('Renders a mustache span with text and css property which contains quotes', function() {
const source = '{{font-family:"trebuchet ms" text}}';
const rendered = Markdown.render(source);
// FIXME: adds extra \s after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="font-family:trebuchet ms;">text</span>');
});
it('Renders a mustache span with text and two css properties which contains quotes', function() {
it.failing('Renders a mustache span with text and two css properties which contains quotes', function() {
const source = '{{font-family:"trebuchet ms",padding:"5px 10px" text}}';
const rendered = Markdown.render(source);
// FIXME: adds extra \s after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="font-family:trebuchet ms; padding:5px 10px;">text</span>');
});
it('Renders a mustache span with text with quotes and css property which contains double quotes', function() {
it.failing('Renders a mustache span with text with quotes and css property which contains quotes', function() {
const source = '{{font-family:"trebuchet ms" text "with quotes"}}';
const rendered = Markdown.render(source);
// FIXME: adds extra \s after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="font-family:trebuchet ms;">text “with quotes”</span>');
});
it('Renders a mustache span with text with quotes and css property which contains double and simple quotes', function() {
const source = `{{--stringVariable:"'string'" text "with quotes"}}`;
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<span class="inline-block" style="--stringVariable:'string';">text “with quotes”</span>`);
});
it('Renders a mustache span with text, id, class and a couple of css properties', function() {
const source = '{{pen,#author,color:orange,font-family:"trebuchet ms" text}}';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block pen" id="author" style="color:orange; font-family:trebuchet ms;">text</span>');
});
it('Renders a span with added attributes', function() {
const source = 'Text and {{pen,#author,color:orange,font-family:"trebuchet ms",a="b and c",d=e, text}} and more text!';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>Text and <span class="inline-block pen" id="author" style="color:orange; font-family:trebuchet ms;" a="b and c" d="e">text</span> and more text!</p>\n');
});
});
// BLOCK SYNTAX
describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
it('Renders a div with text only', function() {
it.failing('Renders a div with text only', function() {
const source = dedent`{{
text
}}`;
text
}}`;
const rendered = Markdown.render(source).trimReturns();
// FIXME: adds extra \s after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block"><p>text</p></div>`);
});
it('Renders an empty div', function() {
it.failing('Renders an empty div', function() {
const source = dedent`{{
}}`;
}}`;
const rendered = Markdown.render(source).trimReturns();
// FIXME: adds extra \s after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block"></div>`);
});
it('Renders a single paragraph with opening and closing brackets', function() {
const source = dedent`{{
}}`;
}}`;
const rendered = Markdown.render(source).trimReturns();
// this actually renders in HB as '{{ }}'...
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>{{}}</p>`);
});
it('Renders a div with a single class', function() {
it.failing('Renders a div with a single class', function() {
const source = dedent`{{cat
}}`;
}}`;
const rendered = Markdown.render(source).trimReturns();
// FIXME: adds two extra \s before closing `>` in opening tag
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block cat"></div>`);
});
it('Renders a div with a single class and text', function() {
it.failing('Renders a div with a single class and text', function() {
const source = dedent`{{cat
Sample text.
}}`;
Sample text.
}}`;
const rendered = Markdown.render(source).trimReturns();
// FIXME: adds two extra \s before closing `>` in opening tag
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block cat"><p>Sample text.</p></div>`);
});
it('Renders a div with two classes and text', function() {
it.failing('Renders a div with two classes and text', function() {
const source = dedent`{{cat,dog
Sample text.
}}`;
Sample text.
}}`;
const rendered = Markdown.render(source).trimReturns();
// FIXME: adds two extra \s before closing `>` in opening tag
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block cat dog"><p>Sample text.</p></div>`);
});
it('Renders a div with a style and text', function() {
it.failing('Renders a div with a style and text', function() {
const source = dedent`{{color:red
Sample text.
}}`;
Sample text.
}}`;
const rendered = Markdown.render(source).trimReturns();
// FIXME: adds two extra \s before closing `>` in opening tag
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" style="color:red;"><p>Sample text.</p></div>`);
});
it('Renders a div with a style that has a string variable, and text', function() {
const source = dedent`{{--stringVariable:"'string'"
Sample text.
}}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" style="--stringVariable:'string';"><p>Sample text.</p></div>`);
});
it('Renders a div with a style that has a string variable, and text', function() {
const source = dedent`{{--stringVariable:"'string'"
Sample text.
}}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" style="--stringVariable:'string';"><p>Sample text.</p></div>`);
});
it('Renders a div with a class, style and text', function() {
it.failing('Renders a div with a class, style and text', function() {
const source = dedent`{{cat,color:red
Sample text.
}}`;
Sample text.
}}`;
const rendered = Markdown.render(source).trimReturns();
// FIXME: adds extra \s after the class attribute
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block cat" style="color:red;"><p>Sample text.</p></div>`);
});
it('Renders a div with an ID, class, style and text (different order)', function() {
const source = dedent`{{color:red,cat,#dog
Sample text.
}}`;
Sample text.
}}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block cat" id="dog" style="color:red;"><p>Sample text.</p></div>`);
});
it('Renders a div with a single ID', function() {
it.failing('Renders a div with a single ID', function() {
const source = dedent`{{#cat,#dog
Sample text.
}}`;
Sample text.
}}`;
const rendered = Markdown.render(source).trimReturns();
// FIXME: adds extra \s before closing `>` in opening tag, and another after class names
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" id="cat"><p>Sample text.</p></div>`);
});
it('Renders a div with an ID, class, style and text, and a variable assignment', function() {
const source = dedent`{{color:red,cat,#dog,a="b and c",d="e"
Sample text.
}}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class=\"block cat\" id=\"dog\" style=\"color:red;\" a=\"b and c\" d=\"e\"><p>Sample text.</p></div>`);
});
it('Renders a div with added attributes', function() {
const source = '{{pen,#author,color:orange,font-family:"trebuchet ms",a="b and c",d=e\nText and text and more text!\n}}\n';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block pen" id="author" style="color:orange; font-family:trebuchet ms;" a="b and c" d="e"><p>Text and text and more text!</p>\n</div>');
});
});
// MUSTACHE INJECTION SYNTAX
@@ -243,218 +223,97 @@ describe(`Block: When using the Block syntax {{tags\\ntext\\n}}`, ()=>{
describe('Injection: When an injection tag follows an element', ()=>{
// FIXME: Most of these fail because injections currently replace attributes, rather than append to. Or just minor extra whitespace issues.
describe('and that element is an inline-block', ()=>{
it('Renders a span "text" with no injection', function() {
it.failing('Renders a span "text" with no injection', function() {
const source = '{{ text}}{}';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block">text</span>');
});
it('Renders a span "text" with injected Class name', function() {
it.failing('Renders a span "text" with injected Class name', function() {
const source = '{{ text}}{ClassName}';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block ClassName">text</span>');
});
it('Renders a span "text" with injected attribute', function() {
const source = '{{ text}}{a="b and c"}';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" a="b and c">text</span>');
});
it('Renders a span "text" with injected style', function() {
it.failing('Renders a span "text" with injected style', function() {
const source = '{{ text}}{color:red}';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="color:red;">text</span>');
});
it('Renders a span "text" with injected style using a string variable', function() {
const source = `{{ text}}{--stringVariable:"'string'"}`;
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<span class="inline-block" style="--stringVariable:'string';">text</span>`);
});
it('Renders a span "text" with two injected styles', function() {
it.failing('Renders a span "text" with two injected styles', function() {
const source = '{{ text}}{color:red,background:blue}';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="color:red; background:blue;">text</span>');
});
it('Renders a span "text" with its own ID, overwritten with an injected ID', function() {
const source = '{{#oldId text}}{#newId}';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" id="newId">text</span>');
});
it('Renders a span "text" with its own attributes, overwritten with an injected attribute, plus a new one', function() {
const source = '{{attrA="old",attrB="old" text}}{attrA="new",attrC="new"}';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" attrA="new" attrB="old" attrC="new">text</span>');
});
it('Renders a span "text" with its own attributes, overwritten with an injected attribute, ignoring "class", "style", and "id"', function() {
const source = '{{attrA="old",attrB="old" text}}{attrA="new",attrC="new",class="new",style="new",id="new"}';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" attrA="new" attrB="old" attrC="new">text</span>');
});
it('Renders a span "text" with its own styles, appended with injected styles', function() {
const source = '{{color:blue,height:10px text}}{width:10px,color:red}';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block" style="color:blue; height:10px; width:10px; color:red;">text</span>');
});
it('Renders a span "text" with its own classes, appended with injected classes', function() {
const source = '{{classA,classB text}}{classA,classC}';
const rendered = Markdown.render(source);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<span class="inline-block classA classB classA classC">text</span>');
});
it('Renders an emphasis element with injected Class name', function() {
it.failing('Renders an emphasis element with injected Class name', function() {
const source = '*emphasis*{big}';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><em class="big">emphasis</em></p>');
});
it('Renders a code element with injected style', function() {
it.failing('Renders a code element with injected style', function() {
const source = '`code`{background:gray}';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><code style="background:gray;">code</code></p>');
});
it('Renders an image element with injected style', function() {
it.failing('Renders an image element with injected style', function() {
const source = '![alt text](http://i.imgur.com/hMna6G0.png){position:absolute}';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><img style="position:absolute;" src="http://i.imgur.com/hMna6G0.png" alt="alt text"></p>');
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><img src="http://i.imgur.com/hMna6G0.png" alt="homebrew mug" style="position:absolute;"></p>');
});
it('Renders an element modified by only the first of two consecutive injections', function() {
it.failing('Renders an element modified by only the first of two consecutive injections', function() {
const source = '{{ text}}{color:red}{background:blue}';
const rendered = Markdown.render(source).trimReturns();
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() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e"></p>`);
});
it('Renders an image with "=" in the url, and added attributes', function() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png?auth=12345&height=1024) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png?auth=12345&height=1024" alt="homebrew mug" a="b and c" d="e"></p>`);
});
it('Renders an image and added attributes with "=" in the value, ', function() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e,otherUrl="url?auth=12345"}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e" otherUrl="url?auth=12345"></p>`);
});
});
describe('and that element is a block', ()=>{
it('renders a div "text" with no injection', function() {
it.failing('renders a div "text" with no injection', function() {
const source = '{{\ntext\n}}\n{}';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block"><p>text</p></div>');
});
it('renders a div "text" with injected Class name', function() {
it.failing('renders a div "text" with injected Class name', function() {
const source = '{{\ntext\n}}\n{ClassName}';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block ClassName"><p>text</p></div>');
});
it('renders a div "text" with injected style', function() {
it.failing('renders a div "text" with injected style', function() {
const source = '{{\ntext\n}}\n{color:red}';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" style="color:red;"><p>text</p></div>');
});
it('renders a div "text" with two injected styles', function() {
it.failing('renders a div "text" with two injected styles', function() {
const source = dedent`{{
text
}}
{color:red,background:blue}`;
text
}}
{color:red,background:blue}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" style="color:red; background:blue;"><p>text</p></div>`);
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" style="color:red; background:blue;"><p>text</p></div>');
});
it('renders a div "text" with injected variable string', function() {
const source = dedent`{{
text
}}
{--stringVariable:"'string'"}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class="block" style="--stringVariable:'string';"><p>text</p></div>`);
});
it('Renders a span "text" with its own ID, overwritten with an injected ID', function() {
const source = dedent`{{#oldId
text
}}
{#newId}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" id="newId"><p>text</p></div>');
});
it('Renders a span "text" with its own attributes, overwritten with an injected attribute, plus a new one', function() {
const source = dedent`{{attrA="old",attrB="old"
text
}}
{attrA="new",attrC="new"}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" attrA="new" attrB="old" attrC="new"><p>text</p></div>');
});
it('Renders a span "text" with its own attributes, overwritten with an injected attribute, ignoring "class", "style", and "id"', function() {
const source = dedent`{{attrA="old",attrB="old"
text
}}
{attrA="new",attrC="new",class="new",style="new",id="new"}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" attrA="new" attrB="old" attrC="new"><p>text</p></div>');
});
it('Renders a span "text" with its own styles, appended with injected styles', function() {
const source = dedent`{{color:blue,height:10px
text
}}
{width:10px,color:red}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block" style="color:blue; height:10px; width:10px; color:red;"><p>text</p></div>');
});
it('Renders a span "text" with its own classes, appended with injected classes', function() {
const source = dedent`{{classA,classB
text
}}
{classA,classC}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block classA classB classA classC"><p>text</p></div>');
});
it('renders an h2 header "text" with injected class name', function() {
it.failing('renders an h2 header "text" with injected class name', function() {
const source = dedent`## text
{ClassName}`;
{ClassName}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<h2 class="ClassName" id="text">text</h2>');
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<h2 class="ClassName">text</h2>');
});
it('renders a table with injected class name', function() {
it.failing('renders a table with injected class name', function() {
const source = dedent`| Experience Points | Level |
|:------------------|:-----:|
| 0 | 1 |
| 300 | 2 |
|:------------------|:-----:|
| 0 | 1 |
| 300 | 2 |
{ClassName}`;
{ClassName}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<table class="ClassName"><thead><tr><th align=left>Experience Points</th><th align=center>Level</th></tr></thead><tbody><tr><td align=left>0</td><td align=center>1</td></tr><tr><td align=left>300</td><td align=center>2</td></tr></tbody></table>`);
});
@@ -470,23 +329,23 @@ describe('Injection: When an injection tag follows an element', ()=>{
// expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`...`); // FIXME: expect this to be injected into <ul>? Currently injects into last <li>
// });
it('renders an h2 header "text" with injected class name, and "secondInjection" as regular text on the next line.', function() {
it.failing('renders an h2 header "text" with injected class name, and "secondInjection" as regular text on the next line.', function() {
const source = dedent`## text
{ClassName}
{secondInjection}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<h2 class="ClassName" id="text">text</h2><p>{secondInjection}</p>');
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<h2 class="ClassName">text</h2><p>{secondInjection}</p>');
});
it('renders a div nested into another div, the inner with class=innerDiv and the other class=outerDiv', function() {
it.failing('renders a div nested into another div, the inner with class=innerDiv and the other class=outerDiv', function() {
const source = dedent`{{
outer text
{{
inner text
}}
{innerDiv}
}}
{outerDiv}`;
outer text
{{
inner text
}}
{innerDiv}
}}
{outerDiv}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<div class="block outerDiv"><p>outer text</p><div class="block innerDiv"><p>inner text</p></div></div>');
});

View File

@@ -1,373 +0,0 @@
/* eslint-disable max-lines */
const dedent = require('dedent-tabs').default;
const Markdown = require('naturalcrit/markdown.js');
// Marked.js adds line returns after closing tags on some default tokens.
// This removes those line returns for comparison sake.
String.prototype.trimReturns = function(){
return this.replace(/\r?\n|\r/g, '').trim();
};
renderAllPages = function(pages){
const outputs = [];
pages.forEach((page, index)=>{
const output = Markdown.render(page, index);
outputs.push(output);
});
return outputs;
};
// Adding `.failing()` method to `describe` or `it` will make failing tests "pass" as long as they continue to fail.
// Remove the `.failing()` method once you have fixed the issue.
describe('Block-level variables', ()=>{
it('Handles variable assignment and recall with simple text', function() {
const source = dedent`
[var]: string
$[var]
`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>string</p>');
});
it('Handles variable assignment and recall with multiline string', function() {
const source = dedent`
[var]: string
across multiple
lines
$[var]`;
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>string across multiple lines</p>');
});
it('Handles variable assignment and recall with tables', function() {
const source = dedent`
[var]:
##### Title
| H1 | H2 |
|:---|:--:|
| A | B |
| C | D |
$[var]`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<h5 id="title">Title</h5>
<table><thead><tr><th align=left>H1</th>
<th align=center>H2</th>
</tr></thead><tbody><tr><td align=left>A</td>
<td align=center>B</td>
</tr><tr><td align=left>C</td>
<td align=center>D</td>
</tr></tbody></table>`.trimReturns());
});
it('Hoists undefined variables', function() {
const source = dedent`
$[var]
[var]: string`;
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>string</p>');
});
it('Hoists last instance of variable', function() {
const source = dedent`
$[var]
[var]: string
[var]: new string`;
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>new string</p>');
});
it('Handles complex hoisting', function() {
const source = dedent`
$[titleAndName]: $[title] $[fullName]
$[title]: Mr.
$[fullName]: $[firstName] $[lastName]
[firstName]: Bob
Welcome, $[titleAndName]!
[lastName]: Jacob
[lastName]: $[lastName]son
`;
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>Welcome, Mr. Bob Jacobson!</p>');
});
it('Handles variable reassignment', function() {
const source = dedent`
[var]: one
$[var]
[var]: two
$[var]
`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>one</p><p>two</p>'.trimReturns());
});
it('Handles variable reassignment with hoisting', function() {
const source = dedent`
$[var]
[var]: one
$[var]
[var]: two
$[var]
`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>two</p><p>one</p><p>two</p>'.trimReturns());
});
it('Ignores undefined variables that can\'t be hoisted', function() {
const source = dedent`
$[var](My name is $[first] $[last])
$[last]: Jones
`;
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>My name is $[first] Jones</p>`.trimReturns());
});
});
describe('Inline-level variables', ()=>{
it('Handles variable assignment and recall with simple text', function() {
const source = dedent`
$[var](string)
$[var]
`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>string</p><p>string</p>');
});
it('Hoists undefined variables when possible', function() {
const source = dedent`
$[var](My name is $[name] Jones)
[name]: Bob`;
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>My name is Bob Jones</p>');
});
it('Hoists last instance of variable', function() {
const source = dedent`
$[var](My name is $[name] Jones)
$[name](Bob)
[name]: Bill`;
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>My name is Bill Jones</p> <p>Bob</p>`.trimReturns());
});
it('Only captures nested parens if balanced', function() {
const source = dedent`
$[var1](A variable (with nested parens) inside)
$[var1]
$[var2](A variable ) with unbalanced parens)
$[var2]`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p>A variable (with nested parens) inside</p>
<p>A variable (with nested parens) inside</p>
<p>A variable with unbalanced parens)</p>
<p>A variable</p>
`.trimReturns());
});
});
describe('Math', ()=>{
it('Handles simple math using numbers only', function() {
const source = dedent`
$[1 + 3 * 5 - (1 / 4)]
`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>15.75</p>');
});
it('Handles round function', function() {
const source = dedent`
$[round(1/4)]`;
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>0</p>');
});
it('Handles floor function', function() {
const source = dedent`
$[floor(0.6)]`;
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>0</p>');
});
it('Handles ceil function', function() {
const source = dedent`
$[ceil(0.2)]`;
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>1</p>');
});
it('Handles nested functions', function() {
const source = dedent`
$[ceil(floor(round(0.6)))]`;
const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>1</p>');
});
it('Handles simple math with variables', function() {
const source = dedent`
$[num1]: 5
$[num2]: 4
Answer is $[answer]($[1 + 3 * num1 - (1 / num2)]).
`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>Answer is 15.75.</p>');
});
it('Handles variable incrementing', function() {
const source = dedent`
$[num1]: 5
Increment num1 to get $[num1]($[num1 + 1]) and again to $[num1]($[num1 + 1]).
`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p>Increment num1 to get 6 and again to 7.</p>');
});
});
describe('Code blocks', ()=>{
it('Ignores all variables in fenced code blocks', function() {
const source = dedent`
\`\`\`
[var]: string
$[var]
$[var](new string)
\`\`\`
`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<pre><code>
[var]: string
$[var]
$[var](new string)
</code></pre>`.trimReturns());
});
it('Ignores all variables in indented code blocks', function() {
const source = dedent`
test
[var]: string
$[var]
$[var](new string)
`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p>test</p>
<pre><code>
[var]: string
$[var]
$[var](new string)
</code></pre>`.trimReturns());
});
it('Ignores all variables in inline code blocks', function() {
const source = '[var](Hello) `[link](url)`. This `[var] does not work`';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p><a href="Hello">var</a> <code>[link](url)</code>. This <code>[var] does not work</code></p>`.trimReturns());
});
});
describe('Normal Links and Images', ()=>{
it('Renders normal images', function() {
const source = `![alt text](url)`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p><img src="url" alt="alt text"></p>`.trimReturns());
});
it('Renders normal images with a title', function() {
const source = 'An image ![alt text](url "and title")!';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p>An image <img src="url" alt="alt text" title="and title">!</p>`.trimReturns());
});
it('Applies curly injectors to images', function() {
const source = `![alt text](url){width:100px}`;
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p><img style="width:100px;" src="url" alt="alt text"></p>`.trimReturns());
});
it('Renders normal links', function() {
const source = 'A Link to my [website](url)!';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p>A Link to my <a href="url">website</a>!</p>`.trimReturns());
});
it('Renders normal links with a title', function() {
const source = 'A Link to my [website](url "and title")!';
const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p>A Link to my <a href="url" title="and title">website</a>!</p>`.trimReturns());
});
});
describe('Cross-page variables', ()=>{
it('Handles variable assignment and recall across pages', function() {
const source0 = `[var]: string`;
const source1 = `$[var]`;
const rendered = renderAllPages([source0, source1]).join('\n\\page\n').trimReturns();
expect(rendered, `Input:\n${[source0, source1].join('\n\\page\n')}`, { showPrefix: false }).toBe('\\page<p>string</p>');
});
it('Handles hoisting across pages', function() {
const source0 = `$[var]`;
const source1 = `[var]: string`;
renderAllPages([source0, source1]).join('\n\\page\n').trimReturns(); //Requires one full render of document before hoisting is picked up
const rendered = renderAllPages([source0, source1]).join('\n\\page\n').trimReturns();
expect(rendered, `Input:\n${[source0, source1].join('\n\\page\n')}`, { showPrefix: false }).toBe('<p>string</p>\\page');
});
it('Handles reassignment and hoisting across pages', function() {
const source0 = `$[var]\n\n[var]: one\n\n$[var]`;
const source1 = `[var]: two\n\n$[var]`;
renderAllPages([source0, source1]).join('\n\\page\n').trimReturns(); //Requires one full render of document before hoisting is picked up
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>');
});
});

View File

@@ -40,7 +40,7 @@ body {
-webkit-column-gap : 1cm;
-moz-column-gap : 1cm;
}
.phb, .page{
.phb{
.useColumns();
counter-increment : phb-page-numbers;
position : relative;
@@ -59,9 +59,6 @@ body {
page-break-before : always;
page-break-after : always;
contain : size;
}
.phb{
//*****************************
// * BASE
// *****************************/

View File

@@ -10,21 +10,6 @@
background-image : url(/assets/DMG_background.png);
background-size : cover;
/*TABLES WITHIN NOTES*/
.note table tbody tr:nth-child(odd) {
background:#fff;
}
/*DROP CAP*/
h1 + p::first-letter {
background-image: unset;
color:black;
}
.quote p:first-child::first-line {
all: unset;
}
&:after {
background-image : url(/assets/DMG_footerAccent.png);
height: 58px;
@@ -40,4 +25,4 @@
.partCover {
background-image: @partCoverHeaderDMG;
}
}
}

View File

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

View File

@@ -8,7 +8,6 @@ const ClassFeatureGen = require('./snippets/classfeature.gen.js');
const CoverPageGen = require('./snippets/coverpage.gen.js');
const TableOfContentsGen = require('./snippets/tableOfContents.gen.js');
const indexGen = require('./snippets/index.gen.js');
const QuoteGen = require('./snippets/quote.gen.js');
const dedent = require('dedent-tabs').default;
@@ -21,43 +20,9 @@ module.exports = [
view : 'text',
snippets : [
{
name : 'Table of Contents',
icon : 'fas fa-book',
gen : TableOfContentsGen,
experimental : true,
subsnippets : [
{
name : 'Table of Contents',
icon : 'fas fa-book',
gen : TableOfContentsGen,
experimental : true
},
{
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',
icon : 'fas fa-book',
gen : TableOfContentsGen
},
{
name : 'Index',
@@ -158,11 +123,6 @@ module.exports = [
icon : 'fas fa-mask',
gen : ClassFeatureGen,
},
{
name : 'Quote',
icon : 'fas fa-quote-right',
gen : QuoteGen,
},
{
name : 'Note',
icon : 'fas fa-sticky-note',
@@ -260,51 +220,34 @@ module.exports = [
view : 'text',
snippets : [
{
name : 'Class Tables',
icon : 'fas fa-table',
gen : ClassTableGen.full('classTable,frame,decoration,wide'),
subsnippets : [
{
name : 'Martial Class Table',
icon : 'fas fa-table',
gen : ClassTableGen.non('classTable,frame,decoration'),
},
{
name : 'Martial Class Table (unframed)',
icon : 'fas fa-border-none',
gen : ClassTableGen.non('classTable'),
},
{
name : 'Full Caster Class Table',
icon : 'fas fa-table',
gen : ClassTableGen.full('classTable,frame,decoration,wide'),
},
{
name : 'Full Caster Class Table (unframed)',
icon : 'fas fa-border-none',
gen : ClassTableGen.full('classTable,wide'),
},
{
name : 'Half Caster Class Table',
icon : 'fas fa-list-alt',
gen : ClassTableGen.half('classTable,frame,decoration,wide'),
},
{
name : 'Half Caster Class Table (unframed)',
icon : 'fas fa-border-none',
gen : ClassTableGen.half('classTable,wide'),
},
{
name : 'Third Caster Spell Table',
icon : 'fas fa-border-all',
gen : ClassTableGen.third('classTable,frame,decoration'),
},
{
name : 'Third Caster Spell Table (unframed)',
icon : 'fas fa-border-none',
gen : ClassTableGen.third('classTable'),
}
]
name : 'Class Table',
icon : 'fas fa-table',
gen : ClassTableGen.full('classTable,frame,decoration,wide'),
},
{
name : 'Class Table (unframed)',
icon : 'fas fa-border-none',
gen : ClassTableGen.full('classTable,wide'),
},
{
name : '1/2 Class Table',
icon : 'fas fa-list-alt',
gen : ClassTableGen.half('classTable,decoration,frame'),
},
{
name : '1/2 Class Table (unframed)',
icon : 'fas fa-border-none',
gen : ClassTableGen.half('classTable'),
},
{
name : '1/3 Class Table',
icon : 'fas fa-border-all',
gen : ClassTableGen.third('classTable,frame'),
},
{
name : '1/3 Class Table (unframed)',
icon : 'fas fa-border-none',
gen : ClassTableGen.third('classTable'),
},
{
name : 'Rune Table',

View File

@@ -42,8 +42,8 @@ module.exports = function(classname){
#### Equipment
You start with the following equipment, in addition to the equipment granted by your background:
- (*a*) a martial weapon and a shield or (*b*) two martial weapons
- (*a*) five javelins or (*b*) any simple melee weapon
- *(a)* a martial weapon and a shield or *(b)* two martial weapons
- *(a)* five javelins or *(b)* any simple melee weapon
- ${_.sample(['10 lint fluffs', '1 button', 'a cherished lost sock'])}
`;
};

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