mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
204 lines
7.6 KiB
Svelte
204 lines
7.6 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import '../app.css';
|
|
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall } from '$lib/stores/app';
|
|
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
|
|
import { get } from 'svelte/store';
|
|
import { darkThemes } from '$lib/platform';
|
|
import ResizeHandles from '$lib/components/ResizeHandles.svelte';
|
|
|
|
let { children } = $props();
|
|
|
|
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
|
|
// Reactively apply theme class to <html> whenever $theme changes
|
|
$effect(() => {
|
|
applyTheme($theme);
|
|
});
|
|
|
|
function applyTheme(t: string) {
|
|
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
|
const root = document.documentElement;
|
|
root.classList.remove('dark');
|
|
root.removeAttribute('data-theme');
|
|
if (namedThemes.includes(t)) {
|
|
root.setAttribute('data-theme', t);
|
|
if (darkThemes.includes(t)) root.classList.add('dark');
|
|
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
root.classList.add('dark');
|
|
}
|
|
}
|
|
|
|
function normalizePath(p: string): string {
|
|
const parts = p.split('/');
|
|
const resolved: string[] = [];
|
|
for (const seg of parts) {
|
|
if (seg === '..') resolved.pop();
|
|
else if (seg !== '.') resolved.push(seg);
|
|
}
|
|
return resolved.join('/');
|
|
}
|
|
|
|
const isAndroid = /android/i.test(navigator.userAgent);
|
|
|
|
function openLocalFile(path: string) {
|
|
if (isAndroid) {
|
|
const bridge = (window as any).Android;
|
|
if (bridge && typeof bridge.openFile === 'function') {
|
|
bridge.openFile(path);
|
|
}
|
|
} else {
|
|
openFile(path).catch((err) => console.error('Failed to open file:', err));
|
|
}
|
|
}
|
|
|
|
function resolveAndHandleLink(href: string) {
|
|
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:')) {
|
|
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
|
|
} else if (!href.startsWith('#')) {
|
|
const decoded = decodeURIComponent(href);
|
|
const config = get(appConfig);
|
|
const vaultRoot = config?.active_vault;
|
|
let absPath = decoded;
|
|
if (!decoded.startsWith('/') && vaultRoot) {
|
|
// .helixnotes/ paths are always relative to vault root, not the note's directory
|
|
if (decoded.startsWith('.helixnotes/')) {
|
|
absPath = normalizePath(`${vaultRoot}/${decoded}`);
|
|
} else {
|
|
const notePath = get(activeNotePath);
|
|
if (notePath) {
|
|
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
|
|
absPath = normalizePath(`${noteDir}/${decoded}`);
|
|
} else {
|
|
absPath = normalizePath(`${vaultRoot}/${decoded}`);
|
|
}
|
|
}
|
|
}
|
|
// Internal .md note link - navigate within the app
|
|
if (absPath.endsWith('.md')) {
|
|
readNote(absPath).then((content) => {
|
|
activeNote.set({ ...content, content: content.content });
|
|
activeNotePath.set(absPath);
|
|
}).catch((err) => console.error('Failed to navigate to note:', err));
|
|
} else {
|
|
openLocalFile(absPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Detect install type and check for updates on startup
|
|
onMount(() => {
|
|
// Authoritative platform from the backend (compile-time), overriding the UA guess. (#63)
|
|
isMobilePlatform().then((m) => platformIsMobile.set(m)).catch(() => {});
|
|
if (isMobile) {
|
|
installType.set('android');
|
|
checkForUpdateMobile();
|
|
} else {
|
|
getInstallType().then(t => {
|
|
installType.set(t);
|
|
if (!isManagedInstall(t)) checkForUpdate();
|
|
}).catch(() => {});
|
|
}
|
|
});
|
|
|
|
// Intercept all link clicks in capture phase to prevent webview navigation
|
|
onMount(() => {
|
|
function handleLinkClick(e: MouseEvent) {
|
|
const el = e.target as HTMLElement;
|
|
// PDF/attachment embeds: check for data-open-file on target or ancestors
|
|
const fileTarget = el?.closest('[data-open-file]') as HTMLElement | null;
|
|
if (fileTarget) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
openLocalFile(fileTarget.getAttribute('data-open-file')!);
|
|
return;
|
|
}
|
|
const target = el?.closest('a');
|
|
if (!target) return;
|
|
const href = target.getAttribute('href');
|
|
if (!href) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
resolveAndHandleLink(href);
|
|
}
|
|
|
|
document.addEventListener('click', handleLinkClick, true);
|
|
|
|
// On Android WebView, tapping a link can trigger native navigation before
|
|
// the JS click event fires (causing 404s for relative .md links).
|
|
// We intercept touchend: if it's a tap (not a scroll) on an <a>, we
|
|
// preventDefault to block the native navigation and handle it ourselves.
|
|
let touchStartY = 0;
|
|
function handleTouchStart(e: TouchEvent) {
|
|
touchStartY = e.touches[0]?.clientY ?? 0;
|
|
}
|
|
function handleTouchEnd(e: TouchEvent) {
|
|
const touch = e.changedTouches[0];
|
|
if (!touch) return;
|
|
const endY = touch.clientY;
|
|
// If the finger moved > 10px, it's a scroll, not a tap
|
|
if (Math.abs(endY - touchStartY) > 10) return;
|
|
// Android WebView may report e.target as the editor root for elements
|
|
// inside contenteditable="false" blocks; use elementFromPoint instead
|
|
const el = (document.elementFromPoint(touch.clientX, touch.clientY) ?? e.target) as HTMLElement;
|
|
// PDF/attachment embeds: check for data-open-file on target or ancestors
|
|
const fileTarget = el?.closest('[data-open-file]') as HTMLElement | null;
|
|
if (fileTarget) {
|
|
e.preventDefault();
|
|
openLocalFile(fileTarget.getAttribute('data-open-file')!);
|
|
return;
|
|
}
|
|
const target = el?.closest('a');
|
|
if (!target) return;
|
|
const href = target.getAttribute('href');
|
|
if (!href) return;
|
|
e.preventDefault();
|
|
resolveAndHandleLink(href);
|
|
}
|
|
|
|
if (isMobile) {
|
|
document.addEventListener('touchstart', handleTouchStart, { capture: true, passive: true });
|
|
document.addEventListener('touchend', handleTouchEnd, true);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('click', handleLinkClick, true);
|
|
if (isMobile) {
|
|
document.removeEventListener('touchstart', handleTouchStart, true);
|
|
document.removeEventListener('touchend', handleTouchEnd, true);
|
|
}
|
|
};
|
|
});
|
|
|
|
// On Windows the native OS drag-drop handler is disabled (dragDropEnabled:false
|
|
// in tauri.windows.conf.json) so HTML5 drag-and-drop works for reordering. With it
|
|
// off, a file dropped outside a drop zone would make the webview navigate to / open
|
|
// that file, replacing the app. Swallow any drag that bubbles up unhandled. Real drop
|
|
// zones (editor, sidebar reordering) call preventDefault in their own handlers first;
|
|
// these bubble-phase listeners only act as a fallback. On macOS/Linux OS file drops are
|
|
// intercepted natively and never surface as HTML5 events, so this is a harmless no-op there.
|
|
onMount(() => {
|
|
function preventNavigate(e: DragEvent) {
|
|
e.preventDefault();
|
|
}
|
|
window.addEventListener('dragover', preventNavigate);
|
|
window.addEventListener('drop', preventNavigate);
|
|
return () => {
|
|
window.removeEventListener('dragover', preventNavigate);
|
|
window.removeEventListener('drop', preventNavigate);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<svelte:document oncontextmenu={(e) => { if (!isMobile) e.preventDefault(); }} />
|
|
|
|
<svelte:head>
|
|
<title>HelixNotes</title>
|
|
</svelte:head>
|
|
|
|
{@render children()}
|
|
|
|
<ResizeHandles />
|