0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2025-12-26 22:32:45 +00:00

Merge branch 'master' into Issue_241_Part_II

This commit is contained in:
David Bolack
2024-08-28 21:17:10 -05:00
24 changed files with 16190 additions and 15548 deletions

View File

@@ -84,6 +84,58 @@ pre {
## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### Tuesday 8/27/2024 - v3.14.2
{{taskList
##### calculuschild
* [x] Reroute invalid urls to homepage
Fixes issues [#3269](https://github.com/naturalcrit/homebrewery/issues/3629)
* [x] Background dependency updates
##### G-Ambatte
* [x] Add route to get brew styling via `/css/shareId`
Fixes issues [#1097](https://github.com/naturalcrit/homebrewery/issues/1097)
* [x] Fix `:emojis:` preventing code folding
Fixes issues [#3604](https://github.com/naturalcrit/homebrewery/issues/3604)
* [x] Fix mask image warping when rotated and stretched
Fixes issues [#3636](https://github.com/naturalcrit/homebrewery/issues/3636)
* [x] Fix Table of Contents uppercasing
Fixes issues [#3572](https://github.com/naturalcrit/homebrewery/issues/3572)
##### abquintic
* [x] Create globally unique Header IDs across pages
Fixes issues [#1430](https://github.com/naturalcrit/homebrewery/issues/1430)
* [x] Fix colon `:::` being parsed in codeblocks
* [x] Prevent crashes when loading undefined renderer or theme bundle
* [x] Add Jump-To hotkeys
* Use `CTRL/META + SHIFT + LEFTARROW` to brewJump
* Use `CTRL/META + SHIFT + RIGHTARROW` to sourceJump
* [x] Prevent reload from clobbering modified fresh clones
##### 5e-Cleric, Gazook89
* [x] Viewer tools for zoom/page navigation
}}
### Tuesday 8/13/2024 - v3.14.1
{{taskList
@@ -509,7 +561,7 @@ Fixes issue [#2729](https://github.com/naturalcrit/homebrewery/issues/2729),
### Thursday 17/08/2023 - v3.9.2
{{taskList
##### Calculuschild
##### calculuschild
* [x] Fix links to certain old Google Drive files
@@ -567,7 +619,7 @@ Fixes issue [#1924](https://github.com/naturalcrit/homebrewery/issues/1924)
### Friday 02/06/2023 - v3.9.0
{{taskList
##### Calculuschild
##### calculuschild
* [x] Fix some files not showing up on userpage when user has a large number of brews in Google Drive
@@ -664,7 +716,7 @@ Fixes issues [#2731](https://github.com/naturalcrit/homebrewery/issues/2731)
### Monday 13/03/2023 - v3.7.2
{{taskList
##### Calculuschild
##### calculuschild
* [x] Fix wide Monster Stat Blocks not spanning columns on Legacy
}}
@@ -687,7 +739,7 @@ Fixes issues [#1569](https://github.com/naturalcrit/homebrewery/issues/1569)
* [x] Updated the Google Drive icon
* [x] Backend fix to unit tests failing intermittently
##### Calculuschild
##### calculuschild
* [x] Fix PDF pixelation on CoverPage text outlines
}}
@@ -699,7 +751,7 @@ Fixes issues [#1569](https://github.com/naturalcrit/homebrewery/issues/1569)
**NOTE:** Some new snippets will now show a {{beta BETA}} tag. Feel free to use them, but be aware we may change how they work depending on your feedback.
}}
##### Calculuschild
##### calculuschild
* [x] New {{openSans **IMAGES → WATERCOLOR EDGE** {{fac,mask-edge}} }} and {{openSans **WATERCOLOR CORNER** {{fac,mask-corner}} }} snippets for V3, which adds a stylish watercolor texture to the edge of your images! (Thanks to /u/flamableconcrete on Reddit for providing these image masks!)
@@ -843,7 +895,7 @@ Fixes issues [#1670](https://github.com/naturalcrit/homebrewery/issues/1670)
### Thursday 28/10/2022 - v3.3.1
{{taskList
##### Calculuschild
##### calculuschild
* [x] Fixes to several broken CSS styles from v3.3.0
@@ -858,7 +910,7 @@ Fixes issues [#2468](https://github.com/naturalcrit/homebrewery/issues/2468)
### Friday 19/10/2022 - v3.3.0
{{taskList
##### Calculuschild
##### calculuschild
* [x] Fix for tables broken by Chrome v106
@@ -941,7 +993,7 @@ Fixes issues [#2317](https://github.com/naturalcrit/homebrewery/issues/2317), [
### Wednesday 31/08/2022 - v3.2.1
{{taskList
##### Calculuschild
##### calculuschild
* [x] Reference Links should now work inside tables
@@ -967,7 +1019,7 @@ Fixes issues [#2317](https://github.com/naturalcrit/homebrewery/issues/2317), [
### Saturday 27/08/2022 - v3.2.0
{{taskList
##### Calculuschild
##### calculuschild
* [x] The V3 renderer is now the default for new brews.
@@ -994,7 +1046,7 @@ Fixes issues [#2317](https://github.com/naturalcrit/homebrewery/issues/2317), [
### Thursday 09/06/2022 - v3.1.1
{{taskList
##### Calculuschild:
##### calculuschild:
* [x] Fixed class table decorations appearing on top of the table in PDF output.
@@ -1838,4 +1890,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

@@ -7,6 +7,7 @@ const _ = require('lodash');
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
const Markdown = require('naturalcrit/markdown.js');
const ErrorBar = require('./errorBar/errorBar.jsx');
const ToolBar = require('./toolBar/toolBar.jsx');
//TODO: move to the brew renderer
const RenderWarnings = require('homebrewery/renderWarnings/renderWarnings.jsx');
@@ -61,10 +62,11 @@ const BrewRenderer = (props)=>{
};
const [state, setState] = useState({
viewablePageNumber : 0,
height : PAGE_HEIGHT,
isMounted : false,
visibility : 'hidden',
height : PAGE_HEIGHT,
isMounted : false,
visibility : 'hidden',
zoom : 100,
currentPageNumber : 1,
});
const mainRef = useRef(null);
@@ -101,6 +103,18 @@ const BrewRenderer = (props)=>{
}));
};
const getCurrentPage = (e)=>{
const { scrollTop, clientHeight, scrollHeight } = e.target;
const totalScrollableHeight = scrollHeight - clientHeight;
const currentPageNumber = Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length);
setState((prevState)=>({
...prevState,
currentPageNumber : currentPageNumber || 1
}));
};
const isInView = (index)=>{
if(!state.isMounted)
return false;
@@ -108,23 +122,12 @@ const BrewRenderer = (props)=>{
if(index == props.currentEditorPage) //Already rendered before this step
return false;
if(Math.abs(index - state.viewablePageNumber) <= 3)
if(Math.abs(index - state.currentPageNumber) <= 3)
return true;
return false;
};
const renderPageInfo = ()=>{
return <div className='pageInfo' ref={mainRef}>
<div>
{props.renderer}
</div>
<div>
{state.viewablePageNumber + 1} / {rawPages.length}
</div>
</div>;
};
const renderDummyPage = (index)=>{
return <div className='phb page' id={`p${index + 1}`} key={index}>
<i className='fas fa-spinner fa-spin' />
@@ -194,11 +197,19 @@ const BrewRenderer = (props)=>{
document.dispatchEvent(new MouseEvent('click'));
};
//Toolbar settings:
const handleZoom = (newZoom)=>{
setState((prevState)=>({
...prevState,
zoom : newZoom
}));
};
return (
<>
{/*render dummy page while iFrame is mounting.*/}
{!state.isMounted
? <div className='brewRenderer' onScroll={handleScroll}>
? <div className='brewRenderer' onScroll={getCurrentPage}>
<div className='pages'>
{renderDummyPage(1)}
</div>
@@ -206,11 +217,13 @@ const BrewRenderer = (props)=>{
: null}
<ErrorBar errors={props.errors} />
<div className='popups'>
<div className='popups' ref={mainRef}>
<RenderWarnings />
<NotificationPopup />
</div>
<ToolBar onZoomChange={handleZoom} currentPage={state.currentPageNumber} totalPages={rawPages.length}/>
{/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
style={{ width: '100%', height: '100%', visibility: state.visibility }}
@@ -218,23 +231,23 @@ const BrewRenderer = (props)=>{
onClick={()=>{emitClick();}}
>
<div className={'brewRenderer'}
onScroll={handleScroll}
onScroll={getCurrentPage}
onKeyDown={handleControlKeys}
tabIndex={-1}
style={{ height: state.height }}>
{/* Apply CSS from Style tab and render pages from Markdown tab */}
{state.isMounted
&&
<>
{renderStyle()}
<div className='pages' lang={`${props.lang || 'en'}`}>
<div className='pages' lang={`${props.lang || 'en'}`} style={{ zoom: `${state.zoom}%` }}>
{renderPages()}
</div>
</>
}
</div>
</Frame>
{renderPageInfo()}
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -120,6 +120,8 @@ const Editor = createClass({
const LEFTARROW_KEY = 37;
const RIGHTARROW_KEY = 39;
if(!e.ctrlKey) return;
if(this.props.liveScroll) {
const movementKeys = [ 13, 33, 34, LEFTARROW_KEY, 38, RIGHTARROW_KEY, 40 ];
if (movementKeys.includes(e.keyCode)) {
@@ -131,10 +133,9 @@ const Editor = createClass({
}
}
if(!(e.ctrlKey || e.metaKey)) return;
// Handle CTRL-HOME and CTRL-END
if(((e.keyCode == END_KEY) || (e.keyCode == HOME_KEY)) && this.props.liveScroll) this.brewJump();
if(!e.metaKey) return;
if (e.shiftKey && (e.keyCode == RIGHTARROW_KEY)) this.brewJump();
if (e.shiftKey && (e.keyCode == LEFTARROW_KEY)) this.sourceJump();
if ((e.keyCode == LEFTARROW_KEY) || (e.keyCode == RIGHTARROW_KEY)) {
@@ -195,8 +196,18 @@ const Editor = createClass({
codeMirror.operation(()=>{ // Batch CodeMirror styling
const foldLines = [];
//reset custom text styles
const customHighlights = codeMirror.getAllMarks().filter((mark)=>!mark.__isFold); //Don't undo code folding
const customHighlights = codeMirror.getAllMarks().filter((mark)=>{
// Record details of folded sections
if(mark.__isFold) {
const fold = mark.find();
foldLines.push({from: fold.from?.line, to: fold.to?.line});
}
return !mark.__isFold;
}); //Don't undo code folding
for (let i=customHighlights.length - 1;i>=0;i--) customHighlights[i].clear();
let editorPageCount = 2; // start page count from page 2
@@ -208,6 +219,11 @@ const Editor = createClass({
codeMirror.removeLineClass(lineNumber, 'text');
codeMirror.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash');
// Don't process lines inside folded text
// If the current lineNumber is inside any folded marks, skip line styling
if (foldLines.some(fold => lineNumber >= fold.from && lineNumber <= fold.to))
return;
// Styling for \page breaks
if((this.props.renderer == 'legacy' && line.includes('\\page')) ||
(this.props.renderer == 'V3' && line.match(/^\\page$/))) {
@@ -320,7 +336,7 @@ const Editor = createClass({
// Iterate over conflicting marks and clear them
var marks = codeMirror.findMarks(startPos, endPos);
marks.forEach(function(marker) {
marker.clear();
if(!marker.__isFold) marker.clear();
});
codeMirror.markText(startPos, endPos, { className: 'emoji' });
}

View File

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

View File

@@ -2,6 +2,9 @@ const dedent = require('dedent-tabs').default;
const loginUrl = 'https://www.naturalcrit.com/login';
//001-050 : Brew errors
//050-100 : Other pages errors
const errorIndex = (props)=>{
return {
// Default catch all
@@ -149,6 +152,14 @@ const errorIndex = (props)=>{
**Brew ID:** ${props.brew.brewId}`,
//account page when account is not defined
'50' : dedent`
## You are not signed in
You are trying to access the account page, but are not signed in to an account.
Please login or signup at our [login page](https://www.naturalcrit.com/login?redirect=https://homebrewery.naturalcrit.com/account).`,
// Brew locked by Administrators error
'100' : dedent`
## This brew has been locked.
@@ -163,4 +174,4 @@ const errorIndex = (props)=>{
};
};
module.exports = errorIndex;
module.exports = errorIndex;

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.3 KiB

30879
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.14.1",
"version": "3.14.2",
"engines": {
"npm": "^10.2.x",
"node": "^20.8.x"
@@ -24,6 +24,7 @@
"test": "jest --runInBand",
"test:api-unit": "jest \"server/.*.spec.js\" --verbose",
"test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose",
"test:api-unit:css": "jest \"server/.*.spec.js\" -t \"Get CSS\" --verbose",
"test:coverage": "jest --coverage --silent --runInBand",
"test:dev": "jest --verbose --watch",
"test:basic": "jest tests/markdown/basic.test.js --verbose",
@@ -88,7 +89,7 @@
"@babel/plugin-transform-runtime": "^7.25.4",
"@babel/preset-env": "^7.25.4",
"@babel/preset-react": "^7.24.7",
"@googleapis/drive": "^8.13.0",
"@googleapis/drive": "^8.13.1",
"body-parser": "^1.20.2",
"classnames": "^2.5.1",
"codemirror": "^5.65.6",
@@ -112,7 +113,7 @@
"marked-smartypants-lite": "^1.0.2",
"markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1",
"mongoose": "^8.5.3",
"mongoose": "^8.5.4",
"nanoid": "3.3.4",
"nconf": "^0.12.1",
"react": "^18.3.1",
@@ -120,12 +121,12 @@
"react-frame-component": "^4.1.3",
"react-router-dom": "6.26.1",
"sanitize-filename": "1.6.3",
"superagent": "^9.0.2",
"superagent": "^10.1.0",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
},
"devDependencies": {
"@stylistic/stylelint-plugin": "^3.0.1",
"eslint": "^9.9.0",
"eslint": "^9.9.1",
"eslint-plugin-jest": "^28.8.0",
"eslint-plugin-react": "^7.35.0",
"globals": "^15.9.0",

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, getUsersBrewThemes, getCSS } = require('./homebrew.api.js');
const GoogleActions = require('./googleActions.js');
const serveCompressedStaticAssets = require('./static-assets.mv.js');
const sanitizeFilename = require('sanitize-filename');
@@ -201,6 +201,9 @@ app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{
res.status(200).send(brew.text);
});
//Serve brew styling
app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);});
//User Page
app.get('/user/:username', async (req, res, next)=>{
const ownAccount = req.account && (req.account.username == req.params.username);
@@ -357,6 +360,15 @@ app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, r
app.get('/account', asyncHandler(async (req, res, next)=>{
const data = {};
data.title = 'Account Information Page';
if(!req.account) {
res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"');
const error = new Error('No valid account');
error.status = 401;
error.HBErrorCode = '50';
error.page = data.title;
return next(error);
};
let auth;
let googleCount = [];

View File

@@ -148,6 +148,20 @@ const api = {
next();
};
},
getCSS : async (req, res)=>{
const { brew } = req;
if(!brew) return res.status(404).send('');
splitTextStyleAndMetadata(brew);
if(!brew.style) return res.status(404).send('');
res.set({
'Cache-Control' : 'no-cache',
'Content-Type' : 'text/css'
});
return res.status(200).send(brew.style);
},
mergeBrewText : (brew)=>{
let text = brew.text;
if(brew.style !== undefined) {

View File

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

View File

@@ -102,6 +102,20 @@ renderer.link = function (href, title, text) {
return out;
};
// Expose `src` attribute as `--HB_src` to make the URL accessible via CSS
renderer.image = function (href, title, text) {
href = cleanUrl(href);
if (href === null)
return text;
let out = `<img src="${href}" alt="${text}" style="--HB_src:url(${href});"`;
if (title)
out += ` title="${title}"`;
out += '>';
return out;
}
// Disable default reflink behavior, as it steps on our variables extension
tokenizer.def = function () {
return undefined;

View File

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

View File

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

View File

@@ -153,6 +153,18 @@ module.exports = [
gen : dedent`
![cat warrior](https://s-media-cache-ak0.pinimg.com/736x/4a/81/79/4a8179462cfdf39054a418efd4cb743e.jpg) {width:325px,mix-blend-mode:multiply}`
},
{
name : 'Image Wrap Left',
icon : 'fac image-wrap-left',
gen : dedent`
![homebrewery_mug](http://i.imgur.com/hMna6G0.png) {width:280px,margin-right:-3cm,wrapLeft}`
},
{
name : 'Image Wrap Right',
icon : 'fac image-wrap-right',
gen : dedent`
![homebrewery_mug](http://i.imgur.com/hMna6G0.png) {width:280px,margin-left:-3cm,wrapRight}`
},
{
name : 'Background Image',
icon : 'fas fa-tree',

View File

@@ -156,6 +156,19 @@ body { counter-reset : page-numbers; }
break-inside : avoid;
}
/* Wrap Text */
.wrapLeft {
shape-outside : var(--HB_src);
float : right;
shape-margin : 0.2cm;
}
.wrapRight {
shape-outside : var(--HB_src);
float : left;
shape-margin : 0.2cm;
}
/* Watermark */
.watermark {
position : absolute;