diff --git a/.circleci/config.yml b/.circleci/config.yml index f18f84943..d405486b5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,6 +76,9 @@ jobs: - run: name: Test - Routes command: npm run test:route + - run: + name: Test - HTML sanitization + command: npm run test:safehtml - run: name: Test - Coverage command: npm run test:coverage diff --git a/client/components/Anchored.jsx b/client/components/Anchored.jsx new file mode 100644 index 000000000..4c7a225e4 --- /dev/null +++ b/client/components/Anchored.jsx @@ -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 and a 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)=>( + +)); + +// forward ref for AnchoredBox +const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>( +
+ {children} +
+)); + +export { Anchored, AnchoredTrigger, AnchoredBox }; diff --git a/client/components/Anchored.less b/client/components/Anchored.less new file mode 100644 index 000000000..4f0e2fa8f --- /dev/null +++ b/client/components/Anchored.less @@ -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; + } +} \ No newline at end of file diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx index 031303def..1c45269cf 100644 --- a/client/homebrew/brewRenderer/brewRenderer.jsx +++ b/client/homebrew/brewRenderer/brewRenderer.jsx @@ -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`
`; + //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
+ const cleanText = safeHTML(props.contents); + return
; }; @@ -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 ?? ''; - return
${cleanStyle} ` }} />; + const cleanStyle = safeHTML(`${themeStyles} \n\n `); + return
; }; const renderPage = (pageText, index)=>{ @@ -137,7 +143,13 @@ const BrewRenderer = (props)=>{ } else { pageText += `\n\n \n\\column\n `; //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 ; + + const styles = { + ...(!displayOptions.pageShadows ? { boxShadow: 'none' } : {}) + // Add more conditions as needed + }; + + return ; } }; @@ -187,12 +199,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 +215,8 @@ const BrewRenderer = (props)=>{ styleObject.backgroundImage = `url("data:image/svg+xml;utf8,${global.config.deployment}")`; } - 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 +235,7 @@ const BrewRenderer = (props)=>{
- + {/*render in iFrame so broken code doesn't crash the site.*/} { && <> {renderedStyle} -
+
{renderedPages}
diff --git a/client/homebrew/brewRenderer/brewRenderer.less b/client/homebrew/brewRenderer/brewRenderer.less index 177576abc..5f08e9455 100644 --- a/client/homebrew/brewRenderer/brewRenderer.less +++ b/client/homebrew/brewRenderer/brewRenderer.less @@ -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; diff --git a/client/homebrew/brewRenderer/safeHTML.js b/client/homebrew/brewRenderer/safeHTML.js new file mode 100644 index 000000000..2574f4cfe --- /dev/null +++ b/client/homebrew/brewRenderer/safeHTML.js @@ -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; \ No newline at end of file diff --git a/client/homebrew/brewRenderer/toolBar/toolBar.jsx b/client/homebrew/brewRenderer/toolBar/toolBar.jsx index 73b48d778..b0300e0e4 100644 --- a/client/homebrew/brewRenderer/toolBar/toolBar.jsx +++ b/client/homebrew/brewRenderer/toolBar/toolBar.jsx @@ -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 ( -
+