Skip to content

Commit

Permalink
Merge pull request #568 from chinchang/html-inlining
Browse files Browse the repository at this point in the history
Html inlining
  • Loading branch information
chinchang authored Jul 7, 2024
2 parents 92355d4 + 05b749a commit 01abd48
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 24 deletions.
71 changes: 71 additions & 0 deletions src/components/Dropdown.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useState, useRef, useEffect } from 'preact/hooks';

const DropdownMenu = ({
btnProps = {},
btnContent,
menuItems,
position = 'top'
}) => {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef(null);
const menuRef = useRef(null);

const toggleDropdown = () => {
setIsOpen(!isOpen);
};

const handleClickOutside = event => {
if (
menuRef.current &&
!menuRef.current.contains(event.target) &&
!triggerRef.current.contains(event.target)
) {
setIsOpen(false);
}
};

useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);

return (
<div className="dropdown">
<button
ref={triggerRef}
onClick={toggleDropdown}
aria-haspopup="true"
aria-expanded={isOpen}
{...btnProps}
className={`dropdown-trigger ${btnProps?.className}`}
>
{btnContent}
</button>
{isOpen && (
<ul
ref={menuRef}
role="menu"
className={`popup dropdown-menu dropdown-menu-${position}`}
>
{menuItems.map((item, index) => (
<li key={index} role="menuitem">
<button
onClick={() => {
setIsOpen(false);
item.onClick();
}}
className="dropdown-item"
>
{item.label}
</button>
</li>
))}
</ul>
)}
</div>
);
};

export { DropdownMenu };
41 changes: 31 additions & 10 deletions src/components/Footer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { I18n } from '@lingui/react';
import { ProBadge } from './ProBadge';
import { HStack } from './Stack';
import { useEffect, useState } from 'preact/hooks';
import { DropdownMenu } from './Dropdown';

const JS13K = props => {
const [daysLeft, setDaysLeft] = useState(0);
Expand Down Expand Up @@ -201,16 +202,36 @@ export const Footer = props => {
) : null}

<div class="footer__right">
<button
onClick={props.saveHtmlBtnClickHandler}
id="saveHtmlBtn"
class="mode-btn hint--rounded hint--top-left hide-on-mobile hide-in-file-mode"
aria-label={i18n._(t`Save as HTML file`)}
>
<svg viewBox="0 0 24 24">
<path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z" />
</svg>
</button>
<DropdownMenu
triggerText="More"
menuItems={[
{
label: 'Download HTML',
onClick: () => {
props.saveHtmlBtnClickHandler();
}
},
{
label: 'Download HTML (assets inlined)',
onClick: () => {
props.saveHtmlBtnClickHandler(true);
}
}
]}
position="top"
btnProps={{
id: 'saveHtmlBtn',
className:
'mode-btn hint--rounded hint--top-left hide-on-mobile hide-in-file-mode',
ariaLabel: i18n._(t`Save as HTML file`)
}}
btnContent={
<svg viewBox="0 0 24 24">
<path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z" />
</svg>
}
/>

<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="codepen-logo" viewBox="0 0 120 120">
<path
Expand Down
4 changes: 2 additions & 2 deletions src/components/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1245,8 +1245,8 @@ export default class App extends Component {
trackEvent('ui', 'openInCodepen');
e.preventDefault();
}
saveHtmlBtnClickHandler(e) {
saveAsHtml(this.state.currentItem);
saveHtmlBtnClickHandler(inlineAssets) {
saveAsHtml(this.state.currentItem, { inlineAssets });
trackEvent('ui', 'saveHtmlClick');
e.preventDefault();
}
Expand Down
46 changes: 46 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,7 @@ body:not(.light-version) .modal {
bottom: calc(100% + 0.2rem);
transition: 0.25s ease;
}
.popup,
.modal__content {
--opaque: 68%;
background: var(--color-popup);
Expand Down Expand Up @@ -2422,6 +2423,51 @@ while the theme CSS file is loading */
} */
}

/* DROPDOWN */
.dropdown {
position: relative;
display: inline-block;
}

.dropdown-menu {
position: absolute;
left: 0;
--opaque: 68%;
background: var(--color-popup);
width: max-content;
list-style: none;
padding: 0.5rem;
margin: 0;
z-index: 1000;
}

.dropdown-menu-top {
bottom: 100%;
margin-bottom: 8px;
}

.dropdown-menu-bottom {
top: 100%;
margin-top: 8px;
}

.dropdown-item {
padding: 0.5rem 1rem;
width: 100%;
text-align: left;
background: none;
border: none;
border-radius: 0.3rem;
cursor: pointer;
color: white;
font-size: 1rem;
}

.dropdown-item:hover,
.dropdown-item:focus {
background-color: #f0f0f033;
}

@media screen and (max-width: 600px) {
body {
font-size: 70%;
Expand Down
79 changes: 67 additions & 12 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -430,30 +430,35 @@ export function getCompleteHtml(html, css, js, item, isForExport) {
'"></script>';
}

if (typeof js === 'string') {
contents += js ? '<script>\n' + js + '\n//# sourceURL=userscript.js' : '';
} else {
var origin = chrome.i18n.getMessage()
? `chrome-extension://${chrome.i18n.getMessage('@@extension_id')}`
: `${location.origin}`;
contents +=
'<script src="' + `filesystem:${origin}/temporary/script.js` + '">';
if (js) {
if (typeof js === 'string') {
contents += js ? '<script>\n' + js + '\n//# sourceURL=userscript.js' : '';
} else {
var origin = chrome.i18n.getMessage()
? `chrome-extension://${chrome.i18n.getMessage('@@extension_id')}`
: `${location.origin}`;
contents +=
'<script src="' + `filesystem:${origin}/temporary/script.js` + '">';
}
contents += '\n</script>';
}
contents += '\n</script>\n</body>\n</html>';
contents += '\n</body>\n</html>';

return contents;
}

export function saveAsHtml(item) {
export function saveAsHtml(item, { inlineAssets }) {
var htmlPromise = computeHtml(item.html, item.htmlMode);
var cssPromise = computeCss(item.css, item.cssMode);
var jsPromise = computeJs(item.js, item.jsMode, false);
Promise.all([htmlPromise, cssPromise, jsPromise]).then(result => {
Promise.all([htmlPromise, cssPromise, jsPromise]).then(async result => {
var html = result[0].code,
css = result[1].code,
js = result[2].code;

var fileContent = getCompleteHtml(html, css, js, item, true);
var fileContent = inlineAssets
? await inlineAssetsInHtml(getCompleteHtml(html, css, js, item, true))
: getCompleteHtml(html, css, js, item, true);

var d = new Date();
var fileName = [
Expand All @@ -480,6 +485,56 @@ export function saveAsHtml(item) {
});
}

export async function inlineAssetsInHtml(html) {
const encodeFileToBase64 = async url => {
const response = await fetch(url);
const blob = await response.blob();
const reader = new FileReader();

return new Promise((resolve, reject) => {
reader.onloadend = () => {
const base64String = reader.result.split(',')[1];
const mimeType = blob.type;
resolve(`data:${mimeType};base64,${base64String}`);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};

const inlineAssets = async htmlContent => {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');

const processElement = async (element, attr) => {
const url = element.getAttribute(attr);
if (url && !url.startsWith('data:')) {
try {
const encodedData = await encodeFileToBase64(url);
element.setAttribute(attr, encodedData);
} catch (error) {
console.error(`Failed to inline ${url}:`, error);
}
}
};

const images = Array.from(doc.querySelectorAll('img'));
const audios = Array.from(doc.querySelectorAll('audio'));
const videos = Array.from(doc.querySelectorAll('video'));

await Promise.all([
...images.map(img => processElement(img, 'src')),
...audios.map(audio => processElement(audio, 'src')),
...videos.map(video => processElement(video, 'src'))
]);

return doc.documentElement.outerHTML;
};

const output = await inlineAssets(html);
// console.log(html, output);
return output;
}
export function handleDownloadsPermission() {
var d = deferred();
if (!window.IS_EXTENSION) {
Expand Down

0 comments on commit 01abd48

Please sign in to comment.