Merge pull request #75 from gamosoft/features/syntax-highlight

Features/syntax highlight
This commit is contained in:
Gamosoft 2025-12-10 10:00:14 +01:00 committed by GitHub
commit 9151e35123
2 changed files with 253 additions and 57 deletions

View File

@ -61,6 +61,10 @@ function noteApp() {
currentTheme: 'light',
availableThemes: [],
// Syntax highlighting
syntaxHighlightEnabled: false,
syntaxHighlightTimeout: null,
// Icon rail / panel state
activePanel: 'files', // 'files', 'search', 'tags', 'settings'
@ -277,6 +281,10 @@ function noteApp() {
// Initialize app
async init() {
// Prevent double initialization (Alpine.js may call x-init twice in some cases)
if (window.__noteapp_initialized) return;
window.__noteapp_initialized = true;
// Store global reference for native event handlers in x-html content
window.$root = this;
@ -297,6 +305,7 @@ function noteApp() {
this.loadEditorWidth();
this.loadViewMode();
this.loadTagsExpanded();
this.loadSyntaxHighlightSetting();
// Parse URL and load specific note if provided
this.loadNoteFromURL();
@ -515,6 +524,110 @@ function noteApp() {
await this.applyTheme(themeId);
},
// Syntax highlighting toggle
toggleSyntaxHighlight() {
this.syntaxHighlightEnabled = !this.syntaxHighlightEnabled;
localStorage.setItem('syntaxHighlightEnabled', this.syntaxHighlightEnabled);
if (this.syntaxHighlightEnabled) {
this.updateSyntaxHighlight();
}
},
loadSyntaxHighlightSetting() {
try {
const saved = localStorage.getItem('syntaxHighlightEnabled');
this.syntaxHighlightEnabled = saved === 'true';
} catch (error) {
console.error('Error loading syntax highlight setting:', error);
}
},
// Update syntax highlight overlay (debounced, called on input)
updateSyntaxHighlight() {
if (!this.syntaxHighlightEnabled) return;
clearTimeout(this.syntaxHighlightTimeout);
this.syntaxHighlightTimeout = setTimeout(() => {
const overlay = document.getElementById('syntax-overlay');
if (overlay) {
overlay.innerHTML = this.highlightMarkdown(this.noteContent);
}
}, 50); // 50ms debounce
},
// Sync overlay scroll with textarea
syncOverlayScroll() {
const textarea = document.getElementById('note-editor');
const overlay = document.getElementById('syntax-overlay');
if (textarea && overlay) {
overlay.scrollTop = textarea.scrollTop;
overlay.scrollLeft = textarea.scrollLeft;
}
},
// Highlight markdown syntax
highlightMarkdown(text) {
if (!text) return '';
// Escape HTML first
let html = this.escapeHtml(text);
// Store code blocks and inline code with placeholders to protect from other patterns
const codePlaceholders = [];
// Frontmatter (must be at start) - protect it
html = html.replace(/^(---\n[\s\S]*?\n---)/m, (match) => {
codePlaceholders.push('<span class="md-frontmatter">' + match + '</span>');
return `\x00CODE${codePlaceholders.length - 1}\x00`;
});
// Code blocks - protect them
html = html.replace(/(```[\s\S]*?```)/g, (match) => {
codePlaceholders.push('<span class="md-codeblock">' + match + '</span>');
return `\x00CODE${codePlaceholders.length - 1}\x00`;
});
// Inline code - protect it
html = html.replace(/`([^`\n]+)`/g, (match) => {
codePlaceholders.push('<span class="md-code">' + match + '</span>');
return `\x00CODE${codePlaceholders.length - 1}\x00`;
});
// Now apply other patterns (they won't match inside protected code)
// Headings
html = html.replace(/^(#{1,6})\s(.*)$/gm, '<span class="md-heading">$1 $2</span>');
// Bold (must come before italic)
html = html.replace(/\*\*([^*]+)\*\*/g, '<span class="md-bold">**$1**</span>');
html = html.replace(/__([^_]+)__/g, '<span class="md-bold">__$1__</span>');
// Italic
html = html.replace(/(?<![*\\])\*([^*\n]+)\*(?!\*)/g, '<span class="md-italic">*$1*</span>');
html = html.replace(/(?<![_\\])_([^_\n]+)_(?!_)/g, '<span class="md-italic">_$1_</span>');
// Wikilinks [[...]]
html = html.replace(/\[\[([^\]]+)\]\]/g, '<span class="md-wikilink">[[$1]]</span>');
// Links [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<span class="md-link">[$1]</span><span class="md-link-url">($2)</span>');
// Lists
html = html.replace(/^(\s*)([-*+])\s/gm, '$1<span class="md-list">$2</span> ');
html = html.replace(/^(\s*)(\d+\.)\s/gm, '$1<span class="md-list">$2</span> ');
// Blockquotes
html = html.replace(/^(&gt;.*)$/gm, '<span class="md-blockquote">$1</span>');
// Horizontal rules
html = html.replace(/^([-*_]{3,})$/gm, '<span class="md-hr">$1</span>');
// Restore protected code blocks
html = html.replace(/\x00CODE(\d+)\x00/g, (match, index) => codePlaceholders[parseInt(index)]);
return html;
},
// Apply theme to document
async applyTheme(themeId) {
// Load theme CSS from file
@ -1801,7 +1914,14 @@ function noteApp() {
// Load note from URL path
loadNoteFromURL() {
// Get path from URL (e.g., /folder/note or /note)
const path = window.location.pathname;
let path = window.location.pathname;
// Strip .md extension if present (for MKdocs/Zensical integration)
if (path.toLowerCase().endsWith('.md')) {
path = path.slice(0, -3);
// Update URL bar to show clean path without .md
window.history.replaceState(null, '', path);
}
// Skip if root path or static assets
if (path === '/' || path.startsWith('/static/') || path.startsWith('/api/')) {

View File

@ -438,6 +438,52 @@
color: var(--text-primary) !important;
}
/* Syntax Highlighting Overlay */
.editor-wrapper {
position: relative;
flex: 1;
display: flex;
min-height: 0;
}
.editor-wrapper .editor-textarea {
position: relative;
z-index: 1;
}
.editor-wrapper.syntax-highlight .editor-textarea {
color: transparent !important;
caret-color: var(--text-primary);
background: transparent !important;
}
.syntax-overlay {
position: absolute;
inset: 0;
padding: 1.5rem;
pointer-events: none;
white-space: pre-wrap;
word-wrap: break-word;
overflow: hidden;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: inherit;
line-height: inherit;
tab-size: 2;
color: var(--text-primary);
background-color: var(--bg-primary);
z-index: 0;
}
/* Syntax highlight colors using theme variables */
.syntax-overlay .md-heading { color: var(--accent-primary); font-weight: 600; }
.syntax-overlay .md-bold { color: var(--text-primary); font-weight: 700; }
.syntax-overlay .md-italic { color: var(--text-secondary); font-style: italic; }
.syntax-overlay .md-code { color: var(--accent-secondary, var(--accent-primary)); background: var(--bg-tertiary); padding: 1px 3px; border-radius: 3px; }
.syntax-overlay .md-link { color: var(--link-color, var(--accent-primary)); }
.syntax-overlay .md-link-url { color: var(--text-tertiary); }
.syntax-overlay .md-list { color: var(--accent-primary); }
.syntax-overlay .md-blockquote { color: var(--text-tertiary); font-style: italic; }
.syntax-overlay .md-hr { color: var(--border-primary); }
.syntax-overlay .md-wikilink { color: var(--link-internal, var(--accent-primary)); }
.syntax-overlay .md-frontmatter { color: var(--text-tertiary); }
.syntax-overlay .md-codeblock { color: var(--text-secondary); background: var(--bg-tertiary); }
/* Link drop zone cursor */
.editor-textarea.link-drop-target {
cursor: copy !important;
@ -1376,11 +1422,29 @@
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary); cursor: pointer;"
>
<template x-for="theme in availableThemes" :key="theme.id">
<option :value="theme.id" x-text="theme.name"></option>
<option :value="theme.id" :selected="theme.id === currentTheme" x-text="theme.name"></option>
</template>
</select>
</div>
<!-- Syntax Highlighting Toggle -->
<div class="mb-4">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs font-medium" style="color: var(--text-secondary);">Editor Syntax Highlight</span>
<div
@click="toggleSyntaxHighlight()"
class="relative w-10 h-5 rounded-full transition-colors cursor-pointer"
:style="syntaxHighlightEnabled ? 'background-color: var(--accent-primary)' : 'background-color: var(--bg-tertiary)'"
>
<div
class="absolute top-0.5 left-0.5 w-4 h-4 rounded-full transition-transform"
:style="'background-color: var(--bg-primary);' + (syntaxHighlightEnabled ? ' transform: translateX(20px);' : '')"
></div>
</div>
</label>
<p class="text-xs mt-1" style="color: var(--text-tertiary);">Colorize markdown syntax in editor</p>
</div>
<!-- Logout (if auth enabled) - uses authEnabled from main app state -->
<div x-show="authEnabled" class="mb-4">
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);">Account</label>
@ -1806,10 +1870,21 @@
style="min-height: 0;"
:style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'"
>
<div
class="editor-wrapper"
:class="{'syntax-highlight': syntaxHighlightEnabled}"
>
<div
id="syntax-overlay"
class="syntax-overlay"
x-show="syntaxHighlightEnabled"
x-html="syntaxHighlightEnabled ? highlightMarkdown(noteContent) : ''"
></div>
<textarea
id="note-editor"
x-model="noteContent"
@input="autoSave()"
@input="autoSave(); updateSyntaxHighlight()"
@scroll="syncOverlayScroll()"
@drop="onEditorDrop($event)"
@dragover.prevent="onEditorDragOver($event)"
@dragenter="onEditorDragEnter($event)"
@ -1820,6 +1895,7 @@
:placeholder="draggedItem && dropTarget === 'editor' ? '💡 Drop here to insert at cursor position...' : 'Start writing in markdown...'"
></textarea>
</div>
</div>
<!-- Split view resize handle -->
<div