585 lines
16 KiB
JavaScript
585 lines
16 KiB
JavaScript
var PAGE_WEBVIEW_LABEL = "page";
|
|
var PAGE_WEBVIEW_LAYER = 0;
|
|
var CHROME_WEBVIEW_LAYER = 10;
|
|
|
|
var form = document.querySelector("#browser-form");
|
|
var addressInput = document.querySelector("#url-input");
|
|
var addressBar = document.querySelector("#address-bar");
|
|
var addressIcon = document.querySelector("#address-icon");
|
|
var suggestions = document.querySelector("#suggestions");
|
|
var backButton = document.querySelector("#back");
|
|
var forwardButton = document.querySelector("#forward");
|
|
var reloadButton = document.querySelector("#reload");
|
|
var statusText = document.querySelector("#status-text");
|
|
var emptyState = document.querySelector("#empty-state");
|
|
var errorState = document.querySelector("#error-state");
|
|
var errorTitle = document.querySelector("#error-title");
|
|
var errorDetail = document.querySelector("#error-detail");
|
|
var errorRetry = document.querySelector("#error-retry");
|
|
var toolbar = document.querySelector("#toolbar");
|
|
|
|
var pageWebView = null;
|
|
var currentUrl = "";
|
|
var navHistory = [];
|
|
var historyIndex = -1;
|
|
var visitedUrls = [];
|
|
var resizeHandle = 0;
|
|
var resizeInFlight = false;
|
|
var resizeRequested = false;
|
|
var viewportPollHandle = 0;
|
|
var isLoading = false;
|
|
var statusTimer = null;
|
|
var zoomLevel = 1.0;
|
|
var ZOOM_STEP = 0.1;
|
|
var ZOOM_MIN = 0.25;
|
|
var ZOOM_MAX = 5.0;
|
|
var suggestActive = -1;
|
|
var suggestFiltered = [];
|
|
// The main WebView is later resized to chrome height, so keep the original
|
|
// full-window viewport for positioning the child page WebView.
|
|
var browserViewport = {
|
|
width: Math.max(1, Math.floor(window.innerWidth)),
|
|
height: Math.max(1, Math.floor(window.innerHeight)),
|
|
};
|
|
var browserViewportRevision = 0;
|
|
var lastNativeResizeAt = 0;
|
|
var nativeViewportInset = null;
|
|
|
|
function setStatus(message, autohide) {
|
|
clearTimeout(statusTimer);
|
|
statusText.textContent = message;
|
|
if (autohide) {
|
|
statusTimer = setTimeout(function () {
|
|
statusText.textContent = "";
|
|
}, autohide);
|
|
}
|
|
}
|
|
|
|
function setLoading(loading) {
|
|
isLoading = loading;
|
|
if (loading) {
|
|
addressBar.classList.remove("load-complete");
|
|
addressBar.classList.add("loading");
|
|
} else {
|
|
addressBar.classList.remove("loading");
|
|
addressBar.classList.add("load-complete");
|
|
setTimeout(function () {
|
|
addressBar.classList.remove("load-complete");
|
|
}, 600);
|
|
}
|
|
}
|
|
|
|
function updateSecurityIndicator(url) {
|
|
if (url.startsWith("https://")) {
|
|
addressBar.classList.add("secure");
|
|
} else {
|
|
addressBar.classList.remove("secure");
|
|
}
|
|
}
|
|
|
|
function normalizeUrl(value) {
|
|
var trimmed = value.trim();
|
|
if (!trimmed) return "https://example.com";
|
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed;
|
|
if (/^[a-z0-9]([a-z0-9-]*\.)+[a-z]{2,}/i.test(trimmed)) return "https://" + trimmed;
|
|
return "https://" + trimmed;
|
|
}
|
|
|
|
function canonicalUrlKey(value) {
|
|
var trimmed = String(value || "").trim();
|
|
if (!trimmed) return "";
|
|
try {
|
|
return new URL(trimmed).href;
|
|
} catch (_) {
|
|
return trimmed.toLowerCase();
|
|
}
|
|
}
|
|
|
|
function toolbarHeight() {
|
|
var toolbarRect = toolbar.getBoundingClientRect();
|
|
return Math.ceil(toolbarRect.height);
|
|
}
|
|
|
|
function chromeHeight() {
|
|
var height = toolbarHeight();
|
|
if (!suggestions.hidden) {
|
|
var suggestionsRect = suggestions.getBoundingClientRect();
|
|
height = Math.max(height, Math.ceil(suggestionsRect.bottom + 8));
|
|
}
|
|
return height;
|
|
}
|
|
|
|
function pageFrame() {
|
|
var top = toolbarHeight();
|
|
top = Math.min(top, Math.max(0, browserViewport.height - 1));
|
|
return {
|
|
x: 0,
|
|
y: top,
|
|
width: browserViewport.width,
|
|
height: Math.max(1, Math.floor(browserViewport.height - top)),
|
|
};
|
|
}
|
|
|
|
function chromeFrame() {
|
|
var height = chromeHeight();
|
|
return {
|
|
x: 0,
|
|
y: 0,
|
|
width: browserViewport.width,
|
|
height: Math.max(1, Math.min(height, browserViewport.height)),
|
|
};
|
|
}
|
|
|
|
function updateBrowserViewport(width, height) {
|
|
var nextWidth = Math.max(1, Math.floor(width));
|
|
var nextHeight = Math.max(1, Math.floor(height));
|
|
if (browserViewport.width === nextWidth && browserViewport.height === nextHeight) return false;
|
|
browserViewport = { width: nextWidth, height: nextHeight };
|
|
browserViewportRevision++;
|
|
return true;
|
|
}
|
|
|
|
function mainWindowInfo(windows) {
|
|
if (!Array.isArray(windows)) return null;
|
|
for (var i = 0; i < windows.length; i++) {
|
|
if (windows[i] && (windows[i].id === 1 || windows[i].label === "main")) return windows[i];
|
|
}
|
|
return windows[0] || null;
|
|
}
|
|
|
|
async function syncBrowserViewport() {
|
|
if (lastNativeResizeAt && Date.now() - lastNativeResizeAt < 1000) return false;
|
|
var startedAtRevision = browserViewportRevision;
|
|
try {
|
|
var info = mainWindowInfo(await window.zero.windows.list());
|
|
if (!info) return false;
|
|
if (browserViewportRevision !== startedAtRevision) return false;
|
|
var nativeWidth = Math.max(1, Math.floor(info.width));
|
|
var nativeHeight = Math.max(1, Math.floor(info.height));
|
|
if (!nativeViewportInset) {
|
|
nativeViewportInset = {
|
|
width: nativeWidth - browserViewport.width,
|
|
height: nativeHeight - browserViewport.height,
|
|
};
|
|
}
|
|
return updateBrowserViewport(
|
|
nativeWidth - nativeViewportInset.width,
|
|
nativeHeight - nativeViewportInset.height
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to sync browser viewport", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function updateChromeOverlay() {
|
|
var frame = chromeFrame();
|
|
await window.zero.webviews.setFrame({ label: "main", frame: frame });
|
|
try {
|
|
await window.zero.webviews.setLayer({ label: "main", layer: CHROME_WEBVIEW_LAYER });
|
|
} catch (error) {
|
|
console.warn("Main WebView layering is not supported on this backend", error);
|
|
}
|
|
}
|
|
|
|
function updateHistoryButtons() {
|
|
backButton.disabled = historyIndex <= 0;
|
|
forwardButton.disabled = historyIndex < 0 || historyIndex >= navHistory.length - 1;
|
|
}
|
|
|
|
function remember(url) {
|
|
if (navHistory[historyIndex] === url) return;
|
|
navHistory = navHistory.slice(0, historyIndex + 1);
|
|
navHistory.push(url);
|
|
historyIndex = navHistory.length - 1;
|
|
updateHistoryButtons();
|
|
}
|
|
|
|
function updateAddressFromPage(url, options) {
|
|
options = options || {};
|
|
if (!url || canonicalUrlKey(url) === canonicalUrlKey(currentUrl)) return;
|
|
currentUrl = url;
|
|
addressInput.value = url;
|
|
updateSecurityIndicator(url);
|
|
hideError();
|
|
trackVisited(url);
|
|
if (options.record !== false) remember(url);
|
|
}
|
|
|
|
function trackVisited(url) {
|
|
var key = canonicalUrlKey(url);
|
|
if (!key) return;
|
|
for (var i = 0; i < visitedUrls.length; i++) {
|
|
if (canonicalUrlKey(visitedUrls[i]) === key) {
|
|
visitedUrls.splice(i, 1);
|
|
break;
|
|
}
|
|
}
|
|
visitedUrls.unshift(url);
|
|
if (visitedUrls.length > 200) visitedUrls.length = 200;
|
|
}
|
|
|
|
function hostFromUrl(url) {
|
|
try {
|
|
return new URL(url).hostname;
|
|
} catch (_) {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
// ── Suggestions ──
|
|
|
|
function escapeHtml(str) {
|
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
function highlightMatch(text, query) {
|
|
if (!query) return escapeHtml(text);
|
|
var lower = text.toLowerCase();
|
|
var qLower = query.toLowerCase();
|
|
var idx = lower.indexOf(qLower);
|
|
if (idx === -1) return escapeHtml(text);
|
|
var before = text.slice(0, idx);
|
|
var match = text.slice(idx, idx + query.length);
|
|
var after = text.slice(idx + query.length);
|
|
return escapeHtml(before) + "<mark>" + escapeHtml(match) + "</mark>" + escapeHtml(after);
|
|
}
|
|
|
|
function filterSuggestions(query) {
|
|
if (!query) return [];
|
|
var q = query.toLowerCase();
|
|
var results = [];
|
|
var seen = Object.create(null);
|
|
for (var i = 0; i < visitedUrls.length; i++) {
|
|
var url = visitedUrls[i];
|
|
var key = canonicalUrlKey(url);
|
|
var matches = url.toLowerCase().indexOf(q) !== -1 || key.toLowerCase().indexOf(q) !== -1;
|
|
if (seen[key] || !matches) continue;
|
|
seen[key] = true;
|
|
results.push(url);
|
|
if (results.length >= 8) break;
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function renderSuggestions(query) {
|
|
suggestFiltered = filterSuggestions(query);
|
|
suggestActive = -1;
|
|
if (suggestFiltered.length === 0) {
|
|
suggestions.hidden = true;
|
|
scheduleResize();
|
|
return;
|
|
}
|
|
var html = "";
|
|
for (var i = 0; i < suggestFiltered.length; i++) {
|
|
var url = suggestFiltered[i];
|
|
var host = hostFromUrl(url);
|
|
html +=
|
|
'<div class="suggestion" data-index="' + i + '">' +
|
|
'<div class="suggestion-icon">' +
|
|
'<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.1"/><path d="M5 5.5h4M5 7h4M5 8.5h2.5" stroke="currentColor" stroke-width="0.9" stroke-linecap="round"/></svg>' +
|
|
'</div>' +
|
|
'<span class="suggestion-url">' + highlightMatch(url, query) + '</span>' +
|
|
'<span class="suggestion-host">' + escapeHtml(host) + '</span>' +
|
|
'</div>';
|
|
}
|
|
suggestions.innerHTML = html;
|
|
suggestions.hidden = false;
|
|
scheduleResize();
|
|
}
|
|
|
|
function hideSuggestions() {
|
|
var wasVisible = !suggestions.hidden;
|
|
suggestions.hidden = true;
|
|
suggestActive = -1;
|
|
suggestFiltered = [];
|
|
if (wasVisible) scheduleResize();
|
|
}
|
|
|
|
function setSuggestActive(index) {
|
|
var items = suggestions.querySelectorAll(".suggestion");
|
|
for (var i = 0; i < items.length; i++) {
|
|
items[i].classList.toggle("active", i === index);
|
|
}
|
|
suggestActive = index;
|
|
if (index >= 0 && index < suggestFiltered.length) {
|
|
addressInput.value = suggestFiltered[index];
|
|
}
|
|
}
|
|
|
|
suggestions.addEventListener("mousedown", function (event) {
|
|
event.preventDefault();
|
|
var item = event.target.closest(".suggestion");
|
|
if (!item) return;
|
|
var idx = parseInt(item.getAttribute("data-index"), 10);
|
|
if (idx >= 0 && idx < suggestFiltered.length) {
|
|
var url = suggestFiltered[idx];
|
|
hideSuggestions();
|
|
navigateTo(url);
|
|
}
|
|
});
|
|
|
|
// ── Error state ──
|
|
|
|
function showError(url, message) {
|
|
var host = hostFromUrl(url);
|
|
errorTitle.textContent = "Can\u2019t connect to " + host;
|
|
errorDetail.textContent = message || "The site could not be reached. Check the address or try again.";
|
|
errorState.hidden = false;
|
|
emptyState.hidden = true;
|
|
}
|
|
|
|
function hideError() {
|
|
errorState.hidden = true;
|
|
}
|
|
|
|
async function closePageWebView() {
|
|
if (!pageWebView) return;
|
|
try {
|
|
await pageWebView.close();
|
|
} catch (error) {
|
|
console.error("Failed to close page WebView", error);
|
|
setStatus(error && error.message ? error.message : "Failed to close page WebView", 3000);
|
|
}
|
|
pageWebView = null;
|
|
}
|
|
|
|
// ── Navigation ──
|
|
|
|
async function ensurePageWebView(url) {
|
|
await syncBrowserViewport();
|
|
var frame = pageFrame();
|
|
if (!pageWebView) {
|
|
await updateChromeOverlay();
|
|
pageWebView = await window.zero.webviews.create({
|
|
label: PAGE_WEBVIEW_LABEL,
|
|
url: url,
|
|
frame: frame,
|
|
layer: PAGE_WEBVIEW_LAYER,
|
|
transparent: false,
|
|
bridge: false,
|
|
});
|
|
await updateChromeOverlay();
|
|
startViewportPolling();
|
|
emptyState.hidden = true;
|
|
} else {
|
|
await pageWebView.setFrame(frame);
|
|
await pageWebView.navigate(url);
|
|
}
|
|
}
|
|
|
|
async function navigateTo(url, options) {
|
|
options = options || {};
|
|
var target = normalizeUrl(url);
|
|
addressInput.value = target;
|
|
updateSecurityIndicator(target);
|
|
hideError();
|
|
setLoading(true);
|
|
setStatus("Loading " + hostFromUrl(target) + "\u2026");
|
|
try {
|
|
await ensurePageWebView(target);
|
|
currentUrl = target;
|
|
trackVisited(target);
|
|
if (options.record !== false) remember(target);
|
|
setLoading(false);
|
|
setStatus(hostFromUrl(target), 2000);
|
|
} catch (error) {
|
|
setLoading(false);
|
|
await closePageWebView();
|
|
var msg = error && error.message ? error.message : "Navigation failed";
|
|
showError(target, msg);
|
|
setStatus("Failed to load " + hostFromUrl(target));
|
|
}
|
|
}
|
|
|
|
async function applyResize() {
|
|
if (!pageWebView) return;
|
|
await updateChromeOverlay();
|
|
var frame = pageFrame();
|
|
await pageWebView.setFrame(frame);
|
|
}
|
|
|
|
async function flushResizeQueue() {
|
|
if (resizeInFlight) {
|
|
resizeRequested = true;
|
|
return;
|
|
}
|
|
resizeInFlight = true;
|
|
try {
|
|
do {
|
|
resizeRequested = false;
|
|
await applyResize();
|
|
} while (resizeRequested);
|
|
} catch (error) {
|
|
setStatus(error.message || "Failed to resize page WebView");
|
|
} finally {
|
|
resizeInFlight = false;
|
|
if (resizeRequested) flushResizeQueue();
|
|
}
|
|
}
|
|
|
|
function scheduleResize() {
|
|
resizeRequested = true;
|
|
if (resizeHandle) cancelAnimationFrame(resizeHandle);
|
|
resizeHandle = requestAnimationFrame(function () {
|
|
resizeHandle = 0;
|
|
flushResizeQueue();
|
|
});
|
|
}
|
|
|
|
function startViewportPolling() {
|
|
if (viewportPollHandle) return;
|
|
viewportPollHandle = setInterval(async function () {
|
|
if (!pageWebView) return;
|
|
if (await syncBrowserViewport()) scheduleResize();
|
|
}, 250);
|
|
}
|
|
|
|
window.zero.on("resize", function (detail) {
|
|
if (!detail) return;
|
|
lastNativeResizeAt = Date.now();
|
|
var changed = updateBrowserViewport(detail.width, detail.height);
|
|
if (changed) scheduleResize();
|
|
});
|
|
|
|
window.zero.on("webview:navigate", function (detail) {
|
|
if (!detail || detail.label !== PAGE_WEBVIEW_LABEL) return;
|
|
updateAddressFromPage(detail.url);
|
|
});
|
|
|
|
// ── Event listeners ──
|
|
|
|
form.addEventListener("submit", function (event) {
|
|
event.preventDefault();
|
|
hideSuggestions();
|
|
addressInput.blur();
|
|
navigateTo(addressInput.value);
|
|
});
|
|
|
|
addressInput.addEventListener("focus", function () {
|
|
addressBar.classList.add("focused");
|
|
addressInput.select();
|
|
renderSuggestions(addressInput.value);
|
|
});
|
|
|
|
addressInput.addEventListener("blur", function () {
|
|
addressBar.classList.remove("focused");
|
|
hideSuggestions();
|
|
});
|
|
|
|
addressInput.addEventListener("input", function () {
|
|
renderSuggestions(addressInput.value);
|
|
});
|
|
|
|
addressInput.addEventListener("keydown", function (event) {
|
|
if (event.key === "Escape") {
|
|
if (!suggestions.hidden) {
|
|
hideSuggestions();
|
|
addressInput.value = currentUrl || addressInput.value;
|
|
return;
|
|
}
|
|
addressInput.value = currentUrl || addressInput.value;
|
|
addressInput.blur();
|
|
return;
|
|
}
|
|
|
|
if (suggestions.hidden || suggestFiltered.length === 0) return;
|
|
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
var next = suggestActive + 1;
|
|
if (next >= suggestFiltered.length) next = 0;
|
|
setSuggestActive(next);
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
var prev = suggestActive - 1;
|
|
if (prev < 0) prev = suggestFiltered.length - 1;
|
|
setSuggestActive(prev);
|
|
} else if (event.key === "Enter" && suggestActive >= 0) {
|
|
event.preventDefault();
|
|
var url = suggestFiltered[suggestActive];
|
|
hideSuggestions();
|
|
navigateTo(url);
|
|
}
|
|
});
|
|
|
|
backButton.addEventListener("click", function () {
|
|
if (historyIndex <= 0) return;
|
|
historyIndex -= 1;
|
|
updateHistoryButtons();
|
|
navigateTo(navHistory[historyIndex], { record: false });
|
|
});
|
|
|
|
forwardButton.addEventListener("click", function () {
|
|
if (historyIndex >= navHistory.length - 1) return;
|
|
historyIndex += 1;
|
|
updateHistoryButtons();
|
|
navigateTo(navHistory[historyIndex], { record: false });
|
|
});
|
|
|
|
reloadButton.addEventListener("click", function () {
|
|
if (isLoading) {
|
|
setLoading(false);
|
|
setStatus("Stopped", 2000);
|
|
return;
|
|
}
|
|
reloadPage();
|
|
});
|
|
|
|
errorRetry.addEventListener("click", function () {
|
|
reloadPage();
|
|
});
|
|
|
|
function reloadPage() {
|
|
navigateTo(currentUrl || addressInput.value, { record: false });
|
|
}
|
|
|
|
async function applyZoom(level) {
|
|
zoomLevel = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(level * 100) / 100));
|
|
if (!pageWebView) return;
|
|
try {
|
|
await pageWebView.setZoom(zoomLevel);
|
|
var pct = Math.round(zoomLevel * 100);
|
|
setStatus(pct + "%", 1200);
|
|
} catch (error) {
|
|
console.error("Failed to zoom page WebView", error);
|
|
setStatus(error && error.message ? error.message : "Failed to zoom page WebView", 3000);
|
|
}
|
|
}
|
|
|
|
window.addEventListener("keydown", function (event) {
|
|
var isMod = event.metaKey || event.ctrlKey;
|
|
if (!isMod) return;
|
|
|
|
if (event.key === "=" || event.key === "+") {
|
|
event.preventDefault();
|
|
applyZoom(zoomLevel + ZOOM_STEP);
|
|
} else if (event.key === "-") {
|
|
event.preventDefault();
|
|
applyZoom(zoomLevel - ZOOM_STEP);
|
|
} else if (event.key === "0") {
|
|
event.preventDefault();
|
|
applyZoom(1.0);
|
|
}
|
|
});
|
|
|
|
window.zero.on("shortcut", function (detail) {
|
|
if (!detail) return;
|
|
if (detail.command === "zoom-in") {
|
|
applyZoom(zoomLevel + ZOOM_STEP);
|
|
} else if (detail.command === "zoom-out") {
|
|
applyZoom(zoomLevel - ZOOM_STEP);
|
|
} else if (detail.command === "zoom-reset") {
|
|
applyZoom(1.0);
|
|
} else if (detail.command === "reload") {
|
|
reloadPage();
|
|
}
|
|
});
|
|
|
|
window.addEventListener("resize", function () {
|
|
scheduleResize();
|
|
});
|
|
window.addEventListener("DOMContentLoaded", function () {
|
|
navigateTo(addressInput.value);
|
|
});
|