Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,006 changes: 975 additions & 31 deletions Cargo.lock

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,18 @@ keywords = ["markdown", "preview", "github", "mermaid", "toc"]
categories = ["command-line-utilities", "text-processing", "web-programming"]
readme = "README.md"

[lib]
name = "markon"
path = "src/lib.rs"

[dependencies]
# Search engine
tantivy = "0.25"
tantivy-jieba = "0.17"

# Database for shared annotations
rusqlite = { version = "0.37.0", features = ["bundled", "limits"] }

clap = { version = "4.5.50", features = ["derive"] }
axum = { version = "0.8.6", features = ["ws"] }
futures-util = "0.3.31"
Expand All @@ -35,5 +46,9 @@ urlencoding = "2.1.3"
qr2term = "0.3.3"
qrcode = "0.14.1"
open = "5.3.2"
rusqlite = { version = "0.37.0", features = ["bundled"] }
dirs = "6.0.0"
walkdir = "2.5.0"
notify = "6.1.1"

[dev-dependencies]
tempfile = "3.17"
1 change: 1 addition & 0 deletions assets/js/core/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const CONFIG = {
ESCAPE: { key: 'Escape', ctrl: false, shift: false, desc: 'Close popups/Clear selection' },
TOGGLE_TOC: { key: '\\', ctrl: true, shift: false, desc: 'Toggle/Focus TOC' },
HELP: { key: '?', ctrl: false, shift: false, desc: 'Show keyboard shortcuts help' },
SEARCH: { key: '/', ctrl: false, shift: false, desc: 'Open search' },

// Navigation
PREV_HEADING: { key: 'k', ctrl: false, shift: false, desc: 'Jump to previous heading' },
Expand Down
41 changes: 37 additions & 4 deletions assets/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { NoteManager } from './managers/note-manager.js';
import { PopoverManager } from './managers/popover-manager.js';
import { UndoManager } from './managers/undo-manager.js';
import { KeyboardShortcutsManager } from './managers/keyboard-shortcuts.js';
import { SearchManager } from './managers/search-manager.js';
import { HighlightManager } from './managers/highlight-manager.js';
import { TOCNavigator } from './navigators/toc-navigator.js';
import { AnnotationNavigator } from './navigators/annotation-navigator.js';
import { ModalManager, showConfirmDialog } from './components/modal.js';
Expand All @@ -30,13 +32,15 @@ export class MarkonApp {
#popoverManager;
#undoManager;
#shortcutsManager;
#searchManager;
#tocNavigator;
#annotationNavigator;

// DOM elements
#markdownBody;
#filePath;
#isSharedMode;
#enableSearch;

// Scroll control
#scrollAnimationId = null;
Expand All @@ -45,6 +49,7 @@ export class MarkonApp {
constructor(config = {}) {
this.#filePath = config.filePath || this.#getFilePathFromMeta();
this.#isSharedMode = config.isSharedMode || false;
this.#enableSearch = config.enableSearch || false;
this.#markdownBody = document.querySelector(CONFIG.SELECTORS.MARKDOWN_BODY);

if (!this.#markdownBody) {
Expand Down Expand Up @@ -79,13 +84,16 @@ export class MarkonApp {
// 5. Setup event listeners
this.#setupEventListeners();

// 6. Register keyboard shortcuts
// 6. Initialize search
this.#initSearch();

// 7. Register keyboard shortcuts
this.#registerShortcuts();

// 7. Fix TOC HTML entities
// 8. Fix TOC HTML entities
this.#fixTocHtmlEntities();

// 8. Update clear button text
// 9. Update clear button text
this.#updateClearButtonText();

Logger.log('MarkonApp', 'Initialization complete');
Expand Down Expand Up @@ -287,6 +295,12 @@ export class MarkonApp {
this.#shortcutsManager.showHelp();
});

if (this.#searchManager) {
this.#shortcutsManager.register('SEARCH', () => {
this.#searchManager.toggle();
});
}

this.#shortcutsManager.register('ESCAPE', () => {
this.#handleEscapeKey();
});
Expand Down Expand Up @@ -1100,6 +1114,22 @@ export class MarkonApp {
}, anchorElement, 'Clear');
}

/**
* Initialize search
* @private
*/
#initSearch() {
if (this.#enableSearch) {
this.#searchManager = new SearchManager();
window.searchManager = this.#searchManager;
Logger.log('MarkonApp', 'SearchManager initialized');
}

// Always initialize highlight manager (works independently of search feature)
new HighlightManager();
Logger.log('MarkonApp', 'HighlightManager initialized');
}

/**
* GetManagement器(用于Debug)
*/
Expand All @@ -1112,6 +1142,7 @@ export class MarkonApp {
popoverManager: this.#popoverManager,
undoManager: this.#undoManager,
shortcutsManager: this.#shortcutsManager,
searchManager: this.#searchManager,
tocNavigator: this.#tocNavigator,
annotationNavigator: this.#annotationNavigator
};
Expand All @@ -1129,6 +1160,7 @@ window.clearPageAnnotations = function(event, ws, isSharedAnnotationMode) {
document.addEventListener('DOMContentLoaded', () => {
const filePathMeta = document.querySelector('meta[name="file-path"]');
const sharedAnnotationMeta = document.querySelector('meta[name="shared-annotation"]');
const enableSearchMeta = document.querySelector('meta[name="enable-search"]');
const isSharedMode = sharedAnnotationMeta?.getAttribute('content') === 'true';

// Settings全局变量供 viewed.js 使用
Expand Down Expand Up @@ -1156,7 +1188,8 @@ document.addEventListener('DOMContentLoaded', () => {

const app = new MarkonApp({
filePath: filePathMeta?.getAttribute('content'),
isSharedMode: isSharedMode
isSharedMode: isSharedMode,
enableSearch: enableSearchMeta?.getAttribute('content') === 'true',
});

app.init();
Expand Down
182 changes: 182 additions & 0 deletions assets/js/managers/highlight-manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* HighlightManager - Handles search term highlighting and scrolling
*/

import { Logger } from '../core/utils.js';

export class HighlightManager {
#markdownBody;
#highlightClass = 'search-highlight';
#activeClass = 'search-highlight-active';

constructor() {
this.#markdownBody = document.querySelector('.markdown-body');
if (!this.#markdownBody) {
Logger.error('HighlightManager', 'Markdown body not found');
return;
}

this.#checkForHighlightParam();
}

#checkForHighlightParam() {
const urlParams = new URLSearchParams(window.location.search);
const highlightQuery = urlParams.get('highlight');

if (highlightQuery && highlightQuery.trim()) {
Logger.log('HighlightManager', 'Highlighting query:', highlightQuery);
this.highlightAndScroll(highlightQuery.trim());
}
}

highlightAndScroll(query) {
// Split query into words for better matching
const words = query.split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) return;

// Find all text nodes in markdown body
const textNodes = this.#getTextNodes(this.#markdownBody);
const matches = [];

// Search for matches
for (const word of words) {
for (const node of textNodes) {
const text = node.textContent;
const lowerText = text.toLowerCase();
const lowerWord = word.toLowerCase();
let startIndex = 0;

while ((startIndex = lowerText.indexOf(lowerWord, startIndex)) !== -1) {
matches.push({
node,
word,
start: startIndex,
end: startIndex + word.length,
text: text.substring(startIndex, startIndex + word.length)
});
startIndex += word.length;
}
}
}

if (matches.length === 0) {
Logger.log('HighlightManager', 'No matches found for query');
return;
}

Logger.log('HighlightManager', `Found ${matches.length} matches`);

// Apply highlights
const highlightedElements = this.#applyHighlights(matches);

if (highlightedElements.length > 0) {
// Scroll to first match
const firstElement = highlightedElements[0];
firstElement.classList.add(this.#activeClass);

setTimeout(() => {
firstElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);

// Remove temporary highlights after animation
setTimeout(() => {
highlightedElements.forEach(el => {
el.classList.remove(this.#activeClass);
});
}, 3000);
}
}

#getTextNodes(element) {
const textNodes = [];
const walk = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// Skip script, style, and empty text nodes
if (node.parentElement.tagName === 'SCRIPT' ||
node.parentElement.tagName === 'STYLE' ||
!node.textContent.trim()) {
return NodeFilter.FILTER_REJECT;
}
// Skip nodes inside existing highlights
if (node.parentElement.classList.contains(this.#highlightClass)) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);

let node;
while ((node = walk.nextNode())) {
textNodes.push(node);
}

return textNodes;
}

#applyHighlights(matches) {
const highlightedElements = [];

// Sort matches by node and position (reverse order for safe replacement)
matches.sort((a, b) => {
if (a.node !== b.node) {
return 0;
}
return b.start - a.start;
});

// Group matches by node
const nodeMatches = new Map();
for (const match of matches) {
if (!nodeMatches.has(match.node)) {
nodeMatches.set(match.node, []);
}
nodeMatches.get(match.node).push(match);
}

// Apply highlights to each node
for (const [node, nodeMatchList] of nodeMatches) {
const parent = node.parentNode;
const text = node.textContent;
const fragments = [];
let lastIndex = 0;

// Sort matches for this node by position
nodeMatchList.sort((a, b) => a.start - b.start);

for (const match of nodeMatchList) {
// Add text before match
if (match.start > lastIndex) {
fragments.push(document.createTextNode(text.substring(lastIndex, match.start)));
}

// Add highlighted match
const span = document.createElement('span');
span.className = this.#highlightClass;
span.textContent = match.text;
fragments.push(span);
highlightedElements.push(span);

lastIndex = match.end;
}

// Add remaining text
if (lastIndex < text.length) {
fragments.push(document.createTextNode(text.substring(lastIndex)));
}

// Replace node with fragments
if (fragments.length > 0) {
for (const fragment of fragments) {
parent.insertBefore(fragment, node);
}
parent.removeChild(node);
}
}

return highlightedElements;
}
}
13 changes: 12 additions & 1 deletion assets/js/managers/keyboard-shortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,13 @@ export class KeyboardShortcutsManager {
handle(event) {
if (!this.#enabled) return false;

// 不拦截Input框内的按键(除了已读复选框)
// Handle search input escape key
const target = event.target;
if (target.id === 'search-input' && event.key === 'Escape') {
return false;
}

// 不拦截Input框内的按键(除了已读复选框)
const isViewedCheckbox = target.classList && target.classList.contains('viewed-checkbox');

if ((target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) && !isViewedCheckbox) {
Expand Down Expand Up @@ -148,6 +153,7 @@ export class KeyboardShortcutsManager {
const categories = {
'Core': ['UNDO', 'REDO', 'REDO_ALT', 'ESCAPE', 'TOGGLE_TOC', 'HELP'],
'Navigation': ['SCROLL_HALF_PAGE_DOWN', 'PREV_HEADING', 'NEXT_HEADING', 'PREV_ANNOTATION', 'NEXT_ANNOTATION'],
'Search (when enabled)': ['SEARCH'],
'Viewed (when enabled)': ['TOGGLE_VIEWED', 'TOGGLE_SECTION_COLLAPSE']
};

Expand All @@ -159,6 +165,11 @@ export class KeyboardShortcutsManager {
html += '<div class="shortcuts-help-content">';

for (const [category, shortcutNames] of Object.entries(categories)) {
// Skip Search Category(如果未启用)
if (category.startsWith('Search') && !document.querySelector('meta[name="enable-search"]')) {
continue;
}

// Skip Viewed Category(如果未启用)
if (category.startsWith('Viewed') && !document.querySelector('meta[name="enable-viewed"]')) {
continue;
Expand Down
Loading