0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-01 23:52:40 +00:00

Merge branch 'master' into localSnippetEditor

This commit is contained in:
David Bolack
2024-11-11 21:21:57 -06:00
23 changed files with 1039 additions and 166 deletions

View File

@@ -0,0 +1,91 @@
import React, { useState, useRef, forwardRef, useEffect, cloneElement, Children } from 'react';
import './Anchored.less';
// Anchored is a wrapper component that must have as children an <AnchoredTrigger> and a <AnchoredBox> component.
// AnchoredTrigger must have a unique `id` prop, which is passed up to Anchored, saved in state on mount, and
// then passed down through props into AnchoredBox. The `id` is used for the CSS Anchor Positioning properties.
// **The Anchor Positioning API is not available in Firefox yet**
// So in Firefox the positioning isn't perfect but is likely sufficient, and FF team seems to be working on the API quickly.
const Anchored = ({ children })=>{
const [visible, setVisible] = useState(false);
const [anchorId, setAnchorId] = useState(null);
const boxRef = useRef(null);
const triggerRef = useRef(null);
// promote trigger id to Anchored id (to pass it back down to the box as "anchorId")
useEffect(()=>{
if(triggerRef.current){
setAnchorId(triggerRef.current.id);
}
}, []);
// close box on outside click or Escape key
useEffect(()=>{
const handleClickOutside = (evt)=>{
if(
boxRef.current &&
!boxRef.current.contains(evt.target) &&
triggerRef.current &&
!triggerRef.current.contains(evt.target)
) {
setVisible(false);
}
};
const handleEscapeKey = (evt)=>{
if(evt.key === 'Escape') setVisible(false);
};
window.addEventListener('click', handleClickOutside);
window.addEventListener('keydown', handleEscapeKey);
return ()=>{
window.removeEventListener('click', handleClickOutside);
window.removeEventListener('keydown', handleEscapeKey);
};
}, []);
const toggleVisibility = ()=>setVisible((prev)=>!prev);
// Map children to inject necessary props
const mappedChildren = Children.map(children, (child)=>{
if(child.type === AnchoredTrigger) {
return cloneElement(child, { ref: triggerRef, toggleVisibility, visible });
}
if(child.type === AnchoredBox) {
return cloneElement(child, { ref: boxRef, visible, anchorId });
}
return child;
});
return <>{mappedChildren}</>;
};
// forward ref for AnchoredTrigger
const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>(
<button
ref={ref}
className={`anchored-trigger${visible ? ' active' : ''} ${className}`}
onClick={toggleVisibility}
style={{ anchorName: `--${props.id}` }} // setting anchor properties here allows greater recyclability.
{...props}
>
{children}
</button>
));
// forward ref for AnchoredBox
const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>(
<div
ref={ref}
className={`anchored-box${visible ? ' active' : ''} ${className}`}
style={{ positionAnchor: `--${anchorId}` }} // setting anchor properties here allows greater recyclability.
{...props}
>
{children}
</div>
));
export { Anchored, AnchoredTrigger, AnchoredBox };

View File

@@ -0,0 +1,13 @@
.anchored-box {
position:absolute;
@supports (inset-block-start: anchor(bottom)){
inset-block-start: anchor(bottom);
}
justify-self: anchor-center;
visibility: hidden;
&.active {
visibility: visible;
}
}

View File

@@ -1,25 +1,25 @@
// Dialog box, for popups and modal blocking messages
import React from "react";
import React from 'react';
const { useRef, useEffect } = React;
function Dialog({ dismisskeys, closeText = 'Close', blocking = false, ...rest }) {
function Dialog({ dismisskeys = [], closeText = 'Close', blocking = false, ...rest }) {
const dialogRef = useRef(null);
useEffect(()=>{
if (dismisskeys.length !== 0) {
if(dismisskeys.length !== 0) {
blocking ? dialogRef.current?.showModal() : dialogRef.current?.show();
}
}, [dialogRef.current, dismisskeys]);
const dismiss = () => {
dismisskeys.forEach(key => {
if (key) {
const dismiss = ()=>{
dismisskeys.forEach((key)=>{
if(key) {
localStorage.setItem(key, 'true');
}
});
dialogRef.current?.close();
};
return (
<dialog ref={dialogRef} onCancel={dismiss} {...rest}>
{rest.children}

View File

@@ -16,8 +16,7 @@ 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 };
import { safeHTML } from './safeHTML.js';
const PAGE_HEIGHT = 1056;
@@ -29,6 +28,7 @@ const INITIAL_CONTENT = dedent`
<base target=_blank>
</head><body style='overflow: hidden'><div></div></body></html>`;
//v=====----------------------< Brew Page Component >---------------------=====v//
const BrewPage = (props)=>{
props = {
@@ -36,8 +36,8 @@ const BrewPage = (props)=>{
index : 0,
...props
};
const cleanText = props.contents; //DOMPurify.sanitize(props.contents, purifyConfig);
return <div className={props.className} id={`p${props.index + 1}`} >
const cleanText = safeHTML(props.contents);
return <div className={props.className} id={`p${props.index + 1}`} style={props.style}>
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
</div>;
};
@@ -65,8 +65,14 @@ const BrewRenderer = (props)=>{
const [state, setState] = useState({
isMounted : false,
visibility : 'hidden',
zoom : 100
visibility : 'hidden'
});
const [displayOptions, setDisplayOptions] = useState({
zoomLevel : 100,
spread : 'single',
startOnRight : true,
pageShadows : true
});
const mainRef = useRef(null);
@@ -77,19 +83,19 @@ const BrewRenderer = (props)=>{
rawPages = props.text.split(/^\\page$/gm);
}
const scrollToHash = (hash) => {
if (!hash) return;
const scrollToHash = (hash)=>{
if(!hash) return;
const iframeDoc = document.getElementById('BrewRenderer').contentDocument;
let anchor = iframeDoc.querySelector(hash);
if (anchor) {
if(anchor) {
anchor.scrollIntoView({ behavior: 'smooth' });
} else {
// Use MutationObserver to wait for the element if it's not immediately available
new MutationObserver((mutations, obs) => {
anchor = iframeDoc.querySelector(hash);
if (anchor) {
new MutationObserver((mutations, obs)=>{
anchor = iframeDoc.querySelector(hash);
if(anchor) {
anchor.scrollIntoView({ behavior: 'smooth' });
obs.disconnect();
}
@@ -125,9 +131,9 @@ const BrewRenderer = (props)=>{
};
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>` }} />;
const cleanStyle = safeHTML(`${themeStyles} \n\n <style> ${props.style} </style>`);
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: cleanStyle }} />;
};
const renderPage = (pageText, index)=>{
@@ -137,7 +143,13 @@ const BrewRenderer = (props)=>{
} 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} />;
const styles = {
...(!displayOptions.pageShadows ? { boxShadow: 'none' } : {})
// Add more conditions as needed
};
return <BrewPage className='page' index={index} key={index} contents={html} style={styles} />;
}
};
@@ -149,7 +161,8 @@ const BrewRenderer = (props)=>{
renderedPages.length = 0;
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
if(rawPages.length > props.currentEditorCursorPageNum -1)
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
_.forEach(rawPages, (page, index)=>{
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
@@ -187,12 +200,14 @@ const BrewRenderer = (props)=>{
document.dispatchEvent(new MouseEvent('click'));
};
//Toolbar settings:
const handleZoom = (newZoom)=>{
setState((prevState)=>({
...prevState,
zoom : newZoom
}));
const handleDisplayOptionsChange = (newDisplayOptions)=>{
setDisplayOptions(newDisplayOptions);
};
const pagesStyle = {
zoom : `${displayOptions.zoomLevel}%`,
columnGap : `${displayOptions.columnGap}px`,
rowGap : `${displayOptions.rowGap}px`
};
const styleObject = {};
@@ -201,8 +216,8 @@ const BrewRenderer = (props)=>{
styleObject.backgroundImage = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='40px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${global.config.deployment}</text></svg>")`;
}
const renderedStyle = useMemo(()=> renderStyle(), [props.style, props.themeBundle]);
renderedPages = useMemo(() => renderPages(), [props.text]);
const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]);
renderedPages = useMemo(()=>renderPages(), [props.text]);
return (
<>
@@ -221,7 +236,7 @@ const BrewRenderer = (props)=>{
<NotificationPopup />
</div>
<ToolBar onZoomChange={handleZoom} currentPage={props.currentBrewRendererPageNum} totalPages={rawPages.length}/>
<ToolBar displayOptions={displayOptions} currentPage={props.currentBrewRendererPageNum} totalPages={rawPages.length} onDisplayOptionsChange={handleDisplayOptionsChange} />
{/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
@@ -240,7 +255,8 @@ const BrewRenderer = (props)=>{
&&
<>
{renderedStyle}
<div className='pages' lang={`${props.lang || 'en'}`} style={{ zoom: `${state.zoom}%` }}>
<div lang={`${props.lang || 'en'}`} style={pagesStyle} className={
`pages ${displayOptions.startOnRight ? 'recto' : 'verso'} ${displayOptions.spread}` } >
{renderedPages}
</div>
</>

View File

@@ -3,13 +3,45 @@
.brewRenderer {
overflow-y : scroll;
will-change : transform;
padding-top : 30px;
padding-top : 60px;
height : 100vh;
&:has(.facing, .flow) {
padding : 60px 30px;
}
&.deployment {
background-color: darkred;
}
:where(.pages) {
margin : 30px 0px;
&.facing {
display: grid;
grid-template-columns: repeat(2, auto);
grid-template-rows: repeat(3, auto);
gap: 10px 10px;
justify-content: center;
&.recto .page:first-child {
// sets first page on 'right' ('recto') of the preview, as if for a Cover page.
// todo: add a checkbox to toggle this setting
grid-column-start: 2;
}
& :where(.page) {
margin-left: unset !important;
margin-right: unset !important;
}
}
&.flow {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-start;
& :where(.page) {
flex: 0 0 auto;
margin-left: unset !important;
margin-right: unset !important;
}
}
& > :where(.page) {
width : 215.9mm;
height : 279.4mm;

View File

@@ -0,0 +1,46 @@
// Derived from the vue-html-secure package, customized for Homebrewery
let doc = null;
let div = null;
function safeHTML(htmlString) {
// If the Document interface doesn't exist, exit
if(typeof document == 'undefined') return null;
// If the test document and div don't exist, create them
if(!doc) doc = document.implementation.createHTMLDocument('');
if(!div) div = doc.createElement('div');
// Set the test div contents to the evaluation string
div.innerHTML = htmlString;
// Grab all nodes from the test div
const elements = div.querySelectorAll('*');
// Blacklisted tags
const blacklistTags = ['script', 'noscript', 'noembed'];
// Tests to remove attributes
const blacklistAttrs = [
(test)=>{return test.localName.indexOf('on') == 0;},
(test)=>{return test.localName.indexOf('type') == 0 && test.value.match(/submit/i);},
(test)=>{return test.value.replace(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g, '').toLowerCase().trim().indexOf('javascript:') == 0;}
];
elements.forEach((element)=>{
// Check each element for blacklisted type
if(blacklistTags.includes(element?.localName?.toLowerCase())) {
element.remove();
return;
}
// Check remaining elements for blacklisted attributes
for (const attribute of element.attributes){
if(blacklistAttrs.some((test)=>{return test(attribute);})) {
element.removeAttribute(attribute.localName);
break;
};
};
});
return div.innerHTML;
};
module.exports.safeHTML = safeHTML;

View File

@@ -3,26 +3,28 @@ const React = require('react');
const { useState, useEffect } = React;
const _ = require('lodash');
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx';
// import * as ZoomIcons from '../../../icons/icon-components/zoomIcons.jsx';
const MAX_ZOOM = 300;
const MIN_ZOOM = 10;
const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChange })=>{
const [zoomLevel, setZoomLevel] = useState(100);
const [pageNum, setPageNum] = useState(currentPage);
const [pageNum, setPageNum] = useState(currentPage);
const [toolsVisible, setToolsVisible] = useState(true);
useEffect(()=>{
onZoomChange(zoomLevel);
}, [zoomLevel]);
useEffect(()=>{
setPageNum(currentPage);
}, [currentPage]);
const handleZoomButton = (zoom)=>{
setZoomLevel(_.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
handleOptionChange('zoomLevel', _.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
};
const handleOptionChange = (optionKey, newValue)=>{
//setDisplayOptions(prevOptions => ({ ...prevOptions, [optionKey]: newValue }));
onDisplayOptionsChange({ ...displayOptions, [optionKey]: newValue });
};
const handlePageInput = (pageInput)=>{
@@ -63,47 +65,51 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
const margin = 5; // extra space so page isn't edge to edge (not truly "to fill")
const deltaZoom = (desiredZoom - zoomLevel) - margin;
const deltaZoom = (desiredZoom - displayOptions.zoomLevel) - margin;
return deltaZoom;
};
return (
<div className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`}>
<div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'>
<button className='toggleButton' title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{setToolsVisible(!toolsVisible);}}><i className='fas fa-glasses' /></button>
{/*v=====----------------------< Zoom Controls >---------------------=====v*/}
<div className='group'>
<div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}>
<button
id='fill-width'
className='tool'
onClick={()=>handleZoomButton(zoomLevel + calculateChange('fill'))}
title='Set zoom to fill preview with one page'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fill'))}
>
<i className='fac fit-width' />
</button>
<button
id='zoom-to-fit'
className='tool'
onClick={()=>handleZoomButton(zoomLevel + calculateChange('fit'))}
title='Set zoom to fit entire page in preview'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fit'))}
>
<i className='fac zoom-to-fit' />
</button>
<button
id='zoom-out'
className='tool'
onClick={()=>handleZoomButton(zoomLevel - 20)}
disabled={zoomLevel <= MIN_ZOOM}
onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)}
disabled={displayOptions.zoomLevel <= MIN_ZOOM}
title='Zoom Out'
>
<i className='fas fa-magnifying-glass-minus' />
</button>
<input
id='zoom-slider'
className='range-input tool'
className='range-input tool hover-tooltip'
type='range'
name='zoom'
title='Set Zoom'
list='zoomLevels'
min={MIN_ZOOM}
max={MAX_ZOOM}
step='1'
value={zoomLevel}
value={displayOptions.zoomLevel}
onChange={(e)=>handleZoomButton(parseInt(e.target.value))}
/>
<datalist id='zoomLevels'>
@@ -113,18 +119,72 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
<button
id='zoom-in'
className='tool'
onClick={()=>handleZoomButton(zoomLevel + 20)}
disabled={zoomLevel >= MAX_ZOOM}
onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)}
disabled={displayOptions.zoomLevel >= MAX_ZOOM}
title='Zoom In'
>
<i className='fas fa-magnifying-glass-plus' />
</button>
</div>
{/*v=====----------------------< Spread Controls >---------------------=====v*/}
<div className='group' role='group' aria-label='Spread' aria-hidden={!toolsVisible}>
<div className='radio-group' role='radiogroup'>
<button role='radio'
id='single-spread'
className='tool'
title='Single Page'
onClick={()=>{handleOptionChange('spread', 'active');}}
aria-checked={displayOptions.spread === 'single'}
><i className='fac single-spread' /></button>
<button role='radio'
id='facing-spread'
className='tool'
title='Facing Pages'
onClick={()=>{handleOptionChange('spread', 'facing');}}
aria-checked={displayOptions.spread === 'facing'}
><i className='fac facing-spread' /></button>
<button role='radio'
id='flow-spread'
className='tool'
title='Flow Pages'
onClick={()=>{handleOptionChange('spread', 'flow');}}
aria-checked={displayOptions.spread === 'flow'}
><i className='fac flow-spread' /></button>
</div>
<Anchored>
<AnchoredTrigger id='spread-settings' className='tool' title='Spread options'><i className='fas fa-gear' /></AnchoredTrigger>
<AnchoredBox title='Options'>
<h1>Options</h1>
<label title='Modify the horizontal space between pages.'>
Column gap
<input type='range' min={0} max={200} defaultValue={10} className='range-input' onChange={(evt)=>handleOptionChange('columnGap', evt.target.value)} />
</label>
<label title='Modify the vertical space between rows of pages.'>
Row gap
<input type='range' min={0} max={200} defaultValue={10} className='range-input' onChange={(evt)=>handleOptionChange('rowGap', evt.target.value)} />
</label>
<label title='Start 1st page on the right side, such as if you have cover page.'>
Start on right
<input type='checkbox' checked={displayOptions.startOnRight} onChange={()=>{handleOptionChange('startOnRight', !displayOptions.startOnRight);}}
title={displayOptions.spread !== 'facing' ? 'Switch to Facing to enable toggle.' : null} />
</label>
<label title='Toggle the page shadow on every page.'>
Page shadows
<input type='checkbox' checked={displayOptions.pageShadows} onChange={()=>{handleOptionChange('pageShadows', !displayOptions.pageShadows);}} />
</label>
</AnchoredBox>
</Anchored>
</div>
{/*v=====----------------------< Page Controls >---------------------=====v*/}
<div className='group'>
<div className='group' role='group' aria-label='Pages' aria-hidden={!toolsVisible}>
<button
id='previous-page'
className='previousPage tool'
type='button'
title='Previous Page(s)'
onClick={()=>scrollToPage(pageNum - 1)}
disabled={pageNum <= 1}
>
@@ -137,6 +197,7 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
className='text-input'
type='text'
name='page'
title='Current page(s) in view'
inputMode='numeric'
pattern='[0-9]'
value={pageNum}
@@ -145,12 +206,14 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
onBlur={()=>scrollToPage(pageNum)}
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
/>
<span id='page-count'>/ {totalPages}</span>
<span id='page-count' title='Total Page Count'>/ {totalPages}</span>
</div>
<button
id='next-page'
className='tool'
type='button'
title='Next Page(s)'
onClick={()=>scrollToPage(pageNum + 1)}
disabled={pageNum >= totalPages}
>

View File

@@ -16,8 +16,8 @@
color : #CCCCCC;
background-color : #555555;
& > *:not(.toggleButton) {
opacity: 1;
transition: all .2s ease;
opacity : 1;
transition : all 0.2s ease;
}
.group {
@@ -34,6 +34,70 @@
align-items : center;
}
.active, [aria-checked='true'] { background-color : #444444; }
.anchored-trigger {
&.active { background-color : #444444; }
}
.anchored-box {
--box-color : #555555;
top : 30px;
display : flex;
flex-direction : column;
gap : 5px;
padding : 15px;
margin-top : 10px;
font-size : 0.8em;
color : #CCCCCC;
background-color : var(--box-color);
border-radius : 5px;
h1 {
padding-bottom : 0.3em;
margin-bottom : 0.5em;
border-bottom : 1px solid currentColor;
}
h2 {
padding-bottom : 0.3em;
margin : 1em 0 0.5em 0;
color : lightgray;
border-bottom : 1px solid currentColor;
}
label {
display : flex;
gap : 6px;
align-items : center;
justify-content : space-between;
}
input {
height : unset;
&[type='range'] { padding : 0; }
}
&::before {
position : absolute;
top : -20px;
left : 50%;
width : 0px;
height : 0px;
pointer-events : none;
content : '';
border : 10px solid transparent;
border-bottom : 10px solid var(--box-color);
transform : translateX(-50%);
}
}
.radio-group:has(button[role='radio']) {
display : flex;
height : 100%;
border : 1px solid #333333;
}
input {
position : relative;
height : 1.5em;
@@ -57,7 +121,7 @@
outline : none;
}
&:hover::after {
&.hover-tooltip[value]:hover::after {
position : absolute;
bottom : -30px;
left : 50%;
@@ -83,7 +147,7 @@
}
button {
box-sizing : content-box;
box-sizing : border-box;
display : flex;
align-items : center;
justify-content : center;
@@ -94,35 +158,36 @@
font-weight : unset;
color : inherit;
background-color : unset;
&:not(button:has(i, svg)) { padding : 0 8px; }
&:hover { background-color : #444444; }
&:focus { outline : 1px solid #D3D3D3; }
&:focus { border : 1px solid #D3D3D3;outline : none;}
&:disabled {
color : #777777;
background-color : unset !important;
}
i {
font-size:1.2em;
}
i { font-size : 1.2em; }
}
&.hidden {
width: 32px;
transition: all .3s ease;
flex-wrap:nowrap;
overflow: hidden;
background-color: unset;
opacity: .5;
flex-wrap : nowrap;
width : 32px;
overflow : hidden;
background-color : unset;
opacity : 0.5;
transition : all 0.3s ease;
& > *:not(.toggleButton) {
opacity: 0;
transition: all .2s ease;
opacity : 0;
transition : all 0.2s ease;
}
}
}
button.toggleButton {
z-index : 5;
position:absolute;
left: 0;
width: 32px;
min-width: unset;
position : absolute;
left : 0;
z-index : 5;
width : 32px;
min-width : unset;
}

View File

@@ -442,6 +442,7 @@ const EditPage = createClass({
{this.renderNavbar()}
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} />}
<div className="content">
<SplitPane onDragFinish={this.handleSplitMove}>
<Editor
ref={this.editor}
@@ -477,6 +478,7 @@ const EditPage = createClass({
allowPrint={true}
/>
</SplitPane>
</div>
</div>;
}
});

View File

@@ -100,6 +100,7 @@ const HomePage = createClass({
return <div className='homePage sitePage'>
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
{this.renderNavbar()}
<div className="content">
<SplitPane onDragFinish={this.handleSplitMove}>
<Editor
ref={this.editor}
@@ -125,6 +126,7 @@ const HomePage = createClass({
themeBundle={this.state.themeBundle}
/>
</SplitPane>
</div>
<div className={cx('floatingSaveButton', { show: this.state.welcomeText != this.state.brew.text })} onClick={this.handleSave}>
Save current <i className='fas fa-save' />
</div>

View File

@@ -223,6 +223,7 @@ const NewPage = createClass({
render : function(){
return <div className='newPage sitePage'>
{this.renderNavbar()}
<div className="content">
<SplitPane onDragFinish={this.handleSplitMove}>
<Editor
ref={this.editor}
@@ -254,6 +255,7 @@ const NewPage = createClass({
allowPrint={true}
/>
</SplitPane>
</div>
</div>;
}
});

View File

@@ -411,10 +411,11 @@ const VaultPage = (props)=>{
};
return (
<div className='vaultPage'>
<div className='sitePage vaultPage'>
<link href='/themes/V3/Blank/style.css' rel='stylesheet' />
<link href='/themes/V3/5ePHB/style.css' rel='stylesheet' />
{renderNavItems()}
<div className="content">
<SplitPane showDividerButtons={false}>
<div className='form dataGroup'>{renderForm()}</div>
<div className='resultsContainer dataGroup'>
@@ -422,6 +423,7 @@ const VaultPage = (props)=>{
{renderFoundBrews()}
</div>
</SplitPane>
</div>
</div>
);
};

View File

@@ -5,7 +5,7 @@
*:not(input) { user-select : none; }
.dataGroup {
.content .dataGroup {
width : 100%;
height : 100%;
background : white;

View File

@@ -0,0 +1,19 @@
import * as IDB from 'idb-keyval/dist/index.js';
export function initCustomStore(db, store){
const createCustomStore = async ()=>IDB.createStore(db, store);
return {
entries : async ()=>IDB.entries(await createCustomStore()),
keys : async ()=>IDB.keys(await createCustomStore()),
values : async ()=>IDB.values(await createCustomStore()),
clear : async ()=>IDB.clear(await createCustomStore),
get : async (key)=>IDB.get(key, await createCustomStore()),
getMany : async (keys)=>IDB.getMany(keys, await createCustomStore()),
set : async (key, value)=>IDB.set(key, value, await createCustomStore()),
setMany : async (entries)=>IDB.setMany(entries, await createCustomStore()),
update : async (key, updateFn)=>IDB.update(key, updateFn, await createCustomStore()),
del : async (key)=>IDB.del(key, await createCustomStore()),
delMany : async (keys)=>IDB.delMany(keys, await createCustomStore())
};
};

View File

@@ -1,4 +1,4 @@
import * as IDB from 'idb-keyval/dist/index.js';
import { initCustomStore } from './customIDBStore.js';
export const HISTORY_PREFIX = 'HOMEBREWERY-HISTORY';
export const HISTORY_SLOTS = 5;
@@ -21,13 +21,15 @@ const HISTORY_SAVE_DELAYS = {
// '5' : 5
// };
const HB_DB = 'HOMEBREWERY-DB';
const HB_STORE = 'HISTORY';
const GARBAGE_COLLECT_DELAY = 28 * 24 * 60;
// const GARBAGE_COLLECT_DELAY = 10;
const HB_DB = 'HOMEBREWERY-DB';
const HB_STORE = 'HISTORY';
const IDB = initCustomStore(HB_DB, HB_STORE);
function getKeyBySlot(brew, slot){
// Return a string representing the key for this brew and history slot
return `${HISTORY_PREFIX}-${brew.shareId}-${slot}`;
@@ -53,11 +55,6 @@ function parseBrewForStorage(brew, slot = 0) {
return [key, archiveBrew];
}
// Create a custom IDB store
async function createHBStore(){
return await IDB.createStore(HB_DB, HB_STORE);
}
export async function loadHistory(brew){
const DEFAULT_HISTORY_ITEM = { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true };
@@ -69,7 +66,7 @@ export async function loadHistory(brew){
};
// Load all keys from IDB at once
const dataArray = await IDB.getMany(historyKeys, await createHBStore());
const dataArray = await IDB.getMany(historyKeys);
return dataArray.map((data)=>{ return data ?? DEFAULT_HISTORY_ITEM; });
}
@@ -97,7 +94,7 @@ export async function updateHistory(brew) {
// Update the most recent brew
historyUpdate.push(parseBrewForStorage(brew, 1));
await IDB.setMany(historyUpdate, await createHBStore());
await IDB.setMany(historyUpdate);
// Break out of data checks because we found an expired value
break;
@@ -106,14 +103,17 @@ export async function updateHistory(brew) {
};
export async function versionHistoryGarbageCollection(){
const entries = await IDB.entries();
const entries = await IDB.entries(await createHBStore());
const expiredKeys = [];
for (const [key, value] of entries){
const expireAt = new Date(value.savedAt);
expireAt.setMinutes(expireAt.getMinutes() + GARBAGE_COLLECT_DELAY);
if(new Date() > expireAt){
await IDB.del(key, await createHBStore());
expiredKeys.push(key);
};
};
if(expiredKeys.length > 0){
await IDB.delMany(expiredKeys);
}
};

View File

@@ -73,3 +73,12 @@
.fit-width {
mask-image: url('../icons/fit-width.svg');
}
.single-spread {
mask-image: url('../icons/single-spread.svg');
}
.facing-spread {
mask-image: url('../icons/facing-spread.svg');
}
.flow-spread {
mask-image: url('../icons/flow-spread.svg');
}

View File

@@ -0,0 +1,10 @@
<?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(0.979101,0,0,0.919064,-29.0748,1.98095)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
<g transform="matrix(0.979101,0,0,0.919064,23.058,1.98095)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,24 @@
<?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.590052,0,0,0.553871,-13.8993,-2.19227)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
<g transform="matrix(0.590052,0,0,0.553871,-13.8993,44.3152)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
<g transform="matrix(0.590052,0,0,0.553871,17.5184,-2.19227)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
<g transform="matrix(0.590052,0,0,0.553871,50.0095,-2.19227)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
<g transform="matrix(0.590052,0,0,0.553871,17.5184,44.3152)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
<g transform="matrix(0.590052,0,0,0.553871,50.0095,44.3152)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,7 @@
<?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.41826,0,0,1.3313,-26.7845,-19.5573)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 777 B