OctoSpaccHub/public/FramesBrowser/index.html

504 lines
18 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<!-- TODO:
* open URL via url hash
* open app in restricted mode via hash
* options menu/zone?
-->
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta property="og:url" content="https://hub.octt.eu.org/FramesBrowser"/>
<link rel="canonical" href="https://hub.octt.eu.org/FramesBrowser"/>
<link rel="shortcut icon" type="image/x-icon" href="../favicon.png"/>
<link rel="manifest" href="./manifest.json"/>
<title>🪟️ Frames Browser (WIP)</title>
<meta name="description" content="iFrame-based HTML5 Browser for fun and development"/>
<meta property="og:title" content="🪟️ Frames Browser (WIP)"/>
<meta property="og:description" content="iFrame-based HTML5 Browser for fun and development"/>
<style>
:root {
--BaseMargin: 8px;
--BtnHeight: calc(1rem + var(--BaseMargin));
--BtnActionHeight: calc(2rem + var(--BaseMargin));
--ColorBg: #f0f0f0;
--ColorFg: #0f0f0f;
}
* {
box-sizing: border-box;
}
body {
margin: 0px;
max-width: 100vw;
max-height: 100vh;
}
button {
height: var(--BtnHeight);
}
iframe {
border: none;
width: 100vw;
height: calc(100vh - var(--BtnActionHeight));
position: relative;
}
#BoxControls {
overflow: auto;
}
#BoxControls table, #BoxControls table td, #BoxControls table td > * {
height: var(--BtnActionHeight);
min-width: var(--BtnActionHeight);
padding-top: 0;
padding-bottom: 0;
border-spacing: 0;
}
#BtnFullscreen {
position: absolute;
top: 0;
right: 0;
z-index: 1;
margin: 8px;
}
.BoxPopup {
left: 0;
right: 0;
}
.BoxPopup.Container {
position: absolute;
top: 10vh;
z-index: 1;
text-align: center;
}
.BoxPopup.Content {
position: relative;
margin-left: auto;
margin-right: auto;
width: fit-content;
max-height: 80vh;
overflow: auto;
box-shadow: gray 4px 4px 4px 0px;
color: var(--ColorFg);
background-color: var(--ColorBg);
}
.BtnAction {
height: var(--BtnActionHeight);
}
</style>
</head>
<body>
<button id="BtnFullscreen" onclick="ToggleFullscreen()">🎞️ Menu</button>
<div id="BoxControls"><table><tr>
<!--<td><button onclick="ShowAppInfo()"> Info</button></td>-->
<td><button onclick="ToggleDevTools()">📐️ Tools</button></td>
<td>
<button id="BtnFile" onclick="this.nextElementSibling.click()">📄 File</button>
<input type="file" hidden="hidden" style="display: none;" onchange="LoadFile(this.files[0])"/>
</td>
<td><button onclick="ZoomFrame()">🔍️ Zoom</button></td>
<td><button onclick="ToggleFullscreen()">🎞️ Hide</button></td>
<td><button onclick="ListFrames()">🪟 Frames</button></td>
<td style="width: 100%;"><input id="InputUri" type="text" style="min-width: 100%;" placeholder="🔗️ Enter URI..." onkeydown="InputHandleKey(event)"/></td>
<td><button id="BtnLoad" onclick="LoadFrame()">↩️ Load</button></td>
<td><button id="BtnExcise" onclick="ExciseFrame()">↗️ Excise</button></td>
<tr></table></div>
<noscript><p class="NoScript">
This is an actual app, not a badly-made website.
<br>
It needs JavaScript to work, so you need to enable it.
<br>
The code is fully open, and you can review it with "View Page Source".
</p></noscript>
<div id="BoxHandy"></div>
<div id="MainAppContent">
<h1>Frames Browser</h1>
<p>Frames Browser is an iFrame-based HTML5 browser made for fun and development. Use the above menu to operate the app.</p>
<p>Note: the app is still in development and data handling may break between versions! Backup your data externally to avoid losing it.</p>
<p>You can also visit, or go back to, my home page (OctoSpacc Hub): <a href="https://hub.octt.eu.org">hub.octt.eu.org</a>!</p>
<h2>Changelog</h2>
<h3>2024-03-19</h3><ul>
<li>Remove info button from toolbar for now.</li>
<!--<li>Make current tab not persist after reload if changed when opened from hash flags.</li>-->
</ul>
<h3>2024-03-18</h3><ul>
<li>First implementation of handling of URL hash parameters (reduced or "quick" format).</li>
<li>Open a <a href="https://hub.octt.eu.org/FramesBrowser/#_1|u=https://example.com">specific URI</a> or <a href="https://hub.octt.eu.org/FramesBrowser/#_1|h=<h2>Hello World!</h2>">raw HTML data</a> via URL hash (click the links to try!).</li>
<li>Add "fullscreen" (hide the main app bar) option to GUI (persistent) and quick URL hash flags.</li>
<li>Fixed some issues with handling UTF8 data URIs.</li>
<li>Make frame zoom option persistent.</li>
</ul>
<h3>2024-03-17</h3><ul>
<li>Improve internal app state data handling (breaking previous version).</li>
<li>Add data dump and reset options appearing on boot to users with pre-update database.</li>
<li>Add link back to my hub in this README.</li>
</ul>
<h3>2024-03-15</h3><ul>
<li>Improve some crusty code.</li>
<li>Add this default/sample HTML text into the app, visible via the otherwise useless Root Window.</li>
<li>Add symbol for indicating current zoom level on zoom button.</li>
<li>Add counter for open frames (goes up to 99 before becoming generic).</li>
<li>Better handling of devtools, removed floating button and always use dedicated button.</li>
</ul>
<h3>2023-09-23 and before</h3><ul>
<li>MVP version of the app with finished UI and basic features.</li>
<li>Eruda DevTools.</li>
<li>In-frame page zoom with 3 levels.</li>
<li>Creating, deleting, and switching to different frames, inputting URL.</li>
<li>Persistent app state saving frames and their URLs.</li>
<li>Excise frame content into another native browser tab (via opening the same URL)</li>
<li>Import file into data URI from local file.</li>
</ul>
<script>
Array.from(document.querySelectorAll('a[href]')).forEach(function(el){
el.onclick = function(){ top.location = this.href }
})
</script>
</div>
<script>
var AppData, SesAppData, SesAppDataBak;
function SaveAppData(){
localStorage.setItem('org.eu.octt.FramesBrowser.v1', JSON.stringify({ ...AppData, ...(SesAppData.optionsFromUrl ? SesAppDataBak : SesAppData) }));
};
var FrameZoomLevels = [50, 200];
var SampleHtmlContent = MainAppContent.innerHTML;
MainAppContent.innerHTML = '<iframe></iframe>';
document.body.style.overflow = 'hidden';
function $new(tag, props){
var el = document.createElement(tag);
if (props) {
Object.keys(props).forEach(function(key){
el[key] = props[key];
});
};
return el;
};
function $request(url, opts){
if (!opts.method) {
opts.method = 'GET';
};
var req = new XMLHttpRequest();
req.onreadystatechange = function(){
if (this.readyState == 4) {
opts.callback(req.responseText);
};
};
req.open(opts.method, url, true);
req.send();
};
function InputHandleKey(ev){
// Enter
if (ev.keyCode == 13) {
LoadFrame();
};
};
function GetTabUrlFromTabIndex (index) {
return (index === -1 ? null : AppData.urls[AppData.tabs[index].urlIndex]);
};
function ShowFrame(index){
var isFrameRoot = (index === -1);
var url = (GetTabUrlFromTabIndex(index) || '');
ListFramesClose();
AppData.currentTabIndex = index;
SaveAppData();
InputUri.disabled = isFrameRoot;
InputUri.value = url;
BtnFile.disabled = isFrameRoot;
BtnLoad.disabled = isFrameRoot;
BtnExcise.disabled = isFrameRoot;
document.querySelector('iframe').src = (isFrameRoot ? `data:text/html;utf8,${encodeURIComponent(SampleHtmlContent)}` : url);
};
function SaveUrl(){
var url = document.querySelector('input[type="text"]').value;
var urlIndex = AppData.urls.indexOf(url);
if (urlIndex === -1) {
// it's a new url, store it
AppData.urls.push(url);
urlIndex = (AppData.urls.length - 1);
}
AppData.tabs[AppData.currentTabIndex].urlIndex = urlIndex;
PruneUnusedUrls();
SaveAppData();
return url;
};
function PruneUnusedUrls(){
// TODO
};
function AddFrame(){
AppData.tabs = AppData.tabs.concat([{}]);
ListFrames();
ShowFrame(AppData.tabs.length - 1);
SaveAppData();
RefreshFramesCounter();
};
function CloseFrame(index){
AppData.tabs.splice(index, 1);
PruneUnusedUrls();
if (AppData.currentTabIndex === index) {
ShowFrame(-1);
} else if (AppData.currentTabIndex > index) {
AppData.currentTabIndex--;
};
ListFrames(); ListFrames();
SaveAppData();
RefreshFramesCounter();
};
function RefreshFramesCounter(){
var count = AppData.tabs.length;
var countHtml = '';
if (count > 99) {
countHtml = '(...)';
} else if (count > 0) {
countHtml = `(${count})`;
};
document.querySelector('button[onclick="ListFrames()"]').textContent = `🪟${countHtml} Frames`;
};
function ApplyFullscreen () {
if (SesAppData.fullscreen) {
BoxControls.style.display = 'none';
BtnFullscreen.style.display = '';
} else {
BoxControls.style.display = '';
BtnFullscreen.style.display = 'none';
}
}
function ToggleFullscreen () {
SesAppData.fullscreen = !SesAppData.fullscreen;
ApplyFullscreen();
SaveAppData();
}
function LoadFrame(){
document.querySelector('iframe').src = SaveUrl();
};
function ExciseFrame(){
var uri = SaveUrl();
if (uri.toLowerCase().startsWith('data:')) {
opendatauri(uri);
} else {
open(uri, '_blank');
};
};
function LoadFile(file){
var reader = new FileReader();
reader.onload = function(){
document.querySelector('input[type="text"]').value = reader.result;
LoadFrame();
};
reader.readAsDataURL(file);
};
function ApplyFrameZoom () {
var level = FrameZoomLevels[SesAppData.frameZoomIndex];
var levelopp = FrameZoomLevels[FrameZoomLevels.length - 1 - SesAppData.frameZoomIndex];
var stylepos = (level < 100
? `right: ${level}vw; bottom: calc(${level}vh - 16px);`
: `left: ${levelopp/2}vw; top: calc(${levelopp/2}vh - 8px);`
);
document.querySelector('iframe').style = (SesAppData.frameZoomIndex === -1
? ''
: `scale: ${level/100}; width: ${levelopp}vw; height: calc(${levelopp}vh - (var(--BtnActionHeight) * ${levelopp / 100})); ${stylepos}`
);
var zoomButton = document.querySelector('button[onclick="ZoomFrame()"]');
if (level < 100) {
zoomButton.textContent = '🔍️(-) Zoom';
} else if (level > 100) {
zoomButton.textContent = '🔍️(+) Zoom';
} else {
zoomButton.textContent = '🔍️ Zoom';
};
}
function ZoomFrame(){
if (SesAppData.frameZoomIndex === FrameZoomLevels.length - 1) {
SesAppData.frameZoomIndex = -1;
} else {
SesAppData.frameZoomIndex ++;
};
ApplyFrameZoom();
SaveAppData();
};
function ListFrames(){
if (!ListFramesClose()){
var Box = NewBoxPopup('BoxFramesList');
var BtnAdd = $new('button', { className: 'BtnAction', innerHTML: ' Add', onclick: AddFrame });
Box.Content.appendChild(BtnAdd);
var BoxList = $new('ul');
Box.Content.appendChild(BoxList);
var LiMain = $new('li');
BoxList.appendChild(LiMain);
var BtnMain = $new('button', { innerHTML: 'Root Window', onclick: /*ShowRootFrame*/function(){ ShowFrame(-1) }, disabled: AppData.currentTabIndex === -1 });
LiMain.appendChild(BtnMain);
for (var i=0; i<AppData.tabs.length; i++) {
var li = $new('li');
li.ItemIndex = i;
BoxList.appendChild(li);
var open = $new('button', { innerHTML: `&nbsp;${(GetTabUrlFromTabIndex(i) || '').slice(0, 16)}&nbsp;`, onclick: function(){ShowFrame(this.parentElement.ItemIndex)}, disabled: AppData.currentTabIndex === i });
li.append(open);
var close = $new('button', { innerHTML: '✖️', onclick: function(){CloseFrame(this.parentElement.ItemIndex)} });
li.append(close);
};
};
};
function ListFramesClose(){
var exist = document.querySelector('#BoxFramesList');
if (exist) {
BoxFramesList.remove();
};
return exist;
};
var isDevToolsOpen = false;
function ToggleDevTools(){
if (typeof(eruda) === 'undefined') {
$request('https://cdn.jsdelivr.net/npm/eruda', { callback: function(text){
eval(text);
eruda.init();
eruda._shadowRoot.querySelector('div.eruda-entry-btn').style.display = 'none';
eruda.show();
isDevToolsOpen = true;
} });
} else {
eruda[isDevToolsOpen ? 'hide' : 'show']();
isDevToolsOpen = !isDevToolsOpen;
}
};
function NewBoxPopup(id){
var Container = $new('div', { id: id, className: 'BoxPopup Container' });
var Content = $new('div', { className: 'BoxPopup Content' });
Container.appendChild(Content);
var BtnClose = $new('button', { className: 'BtnAction', innerHTML: '❌ Close', onclick: function(){this.parentElement.parentElement.remove()} });
Content.appendChild(BtnClose);
BoxHandy.appendChild(Container);
return { Container: Container, Content: Content };
};
function opendatauri (data) {
var head = data.split(',')[0].split('data:')[1];
var [mime, encoding] = head.split(';');
data = data.split(',').slice(1).join(',');
if (encoding.toLowerCase() === 'base64') {
data = atob(data);
} else if (encoding.toLowerCase() === 'utf8') {
data = decodeURIComponent(data);
};
var bytes = new Array(data.length);
for (var i = 0; i < data.length; i++) {
bytes[i] = data.charCodeAt(i);
};
window.open(URL.createObjectURL(
new Blob([new Uint8Array(bytes)], { type: `${mime};${encoding ? encoding : 'utf8'}` })
), '_blank');
};
// https://stackoverflow.com/questions/45053624/convert-hex-to-binary-in-javascript/45054052#45054052
function hex2bin (hex) {
return (parseInt(hex, 16).toString(2)).padStart((hex.length * 4), '0');
}
function AlertMigrateAppData(){
var stored = Object.keys(localStorage);
if (stored.includes('FramesBrowser.CurrentFrames') || stored.includes('FramesBrowser.FrameIndexes') || stored.includes('FramesBrowser.url')) {
var overlay = document.createElement('div');
overlay.style = 'position: absolute; width: 100vw; height: 100vh; top: 0; background: white; color: black;';
overlay.innerHTML = `<p>The app handling of data has changed in the last update. Please copy and backup externally all the data you need and reset the app to continue using it.</p>
<button>Reset</button>
<p>Your open URLs:</p><ul>${JSON.parse(localStorage.getItem('FramesBrowser.CurrentFrames'))?.map(function(item){
return `<li>${item.replaceAll('<','&lt;').replaceAll('>','&gt;')}</li>`;
}).join('') || '<p>[nothing previously saved]</p>'}</ul>`;
overlay.querySelector('button').onclick = function(){
['CurrentFrames','FrameIndexes','url'].forEach(function(key){
localStorage.removeItem(`FramesBrowser.${key}`);
});
location.reload();
};
document.body.appendChild(overlay);
}
};
window.onhashchange = function(){ location.reload() };
window.onload = function(){
Array.from(document.querySelectorAll('noscript, .NoScript')).forEach(function(el){ el.remove() });
AppData = (JSON.parse(localStorage.getItem('org.eu.octt.FramesBrowser.v1')) || {});
SesAppData = {
optionsFromUrl: (AppData.optionsFromUrl || false),
fullscreen: (AppData.fullscreen || false),
frameZoomIndex: (isNaN(parseInt(AppData.frameZoomIndex)) ? -1 : AppData.frameZoomIndex),
};
SesAppDataBak = structuredClone(SesAppData);
AppData = {
tabs: (AppData.tabs || []),
urls: (AppData.urls || []),
currentTabIndex: (isNaN(parseInt(AppData.currentTabIndex)) ? -1 : AppData.currentTabIndex),
};
AlertMigrateAppData();
SaveAppData();
document.querySelector('input[type="text"]').value = GetTabUrlFromTabIndex(AppData.currentTabIndex);
ApplyFullscreen();
ApplyFrameZoom();
ShowFrame(AppData.currentTabIndex);
RefreshFramesCounter();
if (location.hash) {
var tokens = location.hash.slice(1).split('|');
if (tokens[0].startsWith('_')) {
// leading underscore indicates a restricted quick query, succeding charaters are identifiers for versioning
var paramsVersion = tokens[0].slice(1);
var flags = {};
SesAppData.optionsFromUrl = true;
tokens = tokens.slice(1);
switch (paramsVersion) {
case '1':
while (tokens.length > 0) {
var opt = tokens[0];
var optLow = opt.toLowerCase();
if (optLow.startsWith('f=')) {
var flagStr = hex2bin(opt.slice(2));
var flags = {
//disallowPrefsOverride: flagStr[0],
fullscreen: flagStr[3],
};
for (var flag in flags) {
flags[flag] = !!Number(flags[flag]);
}
} else if (optLow.startsWith('u=') || optLow.startsWith('h=')) {
// load data URI, or HTML content
tokens[0] = tokens[0].slice(2);
var fieldData = tokens.join('|');
var url = ((optLow.startsWith('h=') ? 'data:text/html;utf8,' : '') + fieldData);
console.log(url, GetTabUrlFromTabIndex(AppData.currentTabIndex), AppData.currentTabIndex, GetTabUrlFromTabIndex(AppData.currentTabIndex) !== url);
if (GetTabUrlFromTabIndex(AppData.currentTabIndex) !== url) {
AddFrame();
document.querySelector('input[type="text"]').value = url;
LoadFrame();
}
flags.fullscreen && !SesAppData.fullscreen && ToggleFullscreen();
!flags.fullscreen && SesAppData.fullscreen && ToggleFullscreen();
break;
}
tokens = tokens.slice(1);
}
break;
}
}
}
};
</script>
</body>
</html>