ContentSearcher.ts, added to ScriptInstallPrompt.ts and Settings.ts

This commit is contained in:
zsviczian
2025-07-20 21:07:03 +02:00
parent 972fe1baea
commit 207fea3f57
6 changed files with 368 additions and 214 deletions

View File

@@ -46,6 +46,7 @@ import { HotkeyEditor } from "src/shared/Dialogs/HotkeyEditor";
import { getExcalidrawViews } from "src/utils/obsidianUtils";
import { createSliderWithText } from "src/utils/sliderUtils";
import { PDFExportSettingsComponent, PDFExportSettings } from "src/shared/Dialogs/PDFExportSettingsComponent";
import { ContentSearcher } from "src/shared/components/ContentSearcher";
export interface ExcalidrawSettings {
disableDoubleClickTextEditing: boolean;
@@ -668,35 +669,8 @@ export class ExcalidrawSettingTab extends PluginSettingTab {
// Search and Settings to Clipboard
// ------------------------------------------------
const settingActions = containerEl.createDiv("ex-settings-actions");
const settingActionsContainer = settingActions.createDiv("setting-item-description ex-setting-actions-container");
const clipboardActionEl = settingActionsContainer.createEl("a", {attr: { "aria-label": t("SETTINGS_COPY_TO_CLIPBOARD_ARIA") }});
clipboardActionEl.innerHTML = getIcon("clipboard-copy").outerHTML + t("SETTINGS_COPY_TO_CLIPBOARD");
clipboardActionEl.onClickEvent(e => {
// Get the full HTML content first
const fullHtml = containerEl.outerHTML;
// Find the index of the first <hr> element
const startIndex = fullHtml.indexOf('<hr');
// Extract HTML from the first <hr> element onwards
const html = startIndex > -1 ? fullHtml.substring(startIndex) : fullHtml;
function replaceHeading(html:string,level:number):string {
const re = new RegExp(`<summary class="excalidraw-setting-h${level}">([^<]+)<\/summary>`,"g");
return html.replaceAll(re,`<summary class="excalidraw-setting-h${level}"><h${level}>$1</h${level}></summary>`);
}
let x = replaceHeading(html,1);
x = replaceHeading(x,2);
x = replaceHeading(x,3);
x = replaceHeading(x,4);
x = x.replaceAll(/<div class="setting-item-name">([^<]+)<\/div>/g,"<h5>$1</h5>");
const md = htmlToMarkdown(x);
window.navigator.clipboard.writeText(md);
new Notice(t("SETTINGS_COPIED_TO_CLIPBOARD"));
});
const searcher = new ContentSearcher(containerEl);
containerEl.prepend(searcher.getSearchBarWrapper());
// ------------------------------------------------
// Saving

View File

@@ -192,6 +192,14 @@ export default {
SAVE_IS_TAKING_LONG: "Saving your previous file is taking a long time. Please wait...",
SAVE_IS_TAKING_VERY_LONG: "For better performance, consider splitting large drawings into several smaller files.",
//ContentSearcher.ts
SEARCH_COPIED_TO_CLIPBOARD: "Markdown ready on clipboard",
SEARCH_COPY_TO_CLIPBOARD_ARIA: "Copy the entire dialog to the clipboard as Markdown. Ideal for use with tools like ChatGPT to search and understand.",
SEARCH_NEXT: "Next",
SEARCH_PREVIOUS: "Previous",
//settings.ts
LINKS_BUGS_ARIA: "Report bugs and raise feature requsts on the plugin's GitHub page",
LINKS_BUGS: "Report Bugs",
@@ -205,10 +213,6 @@ export default {
LINKS_BOOK_ARIA: "Read Sketch Your Mind, my book on Visual Thinking",
LINKS_BOOK: "Read the Book",
SETTINGS_COPIED_TO_CLIPBOARD: "Markdown ready on clipboard",
SETTINGS_COPY_TO_CLIPBOARD: "Copy as Text",
SETTINGS_COPY_TO_CLIPBOARD_ARIA: "Copy the entire settings dialog to the clipboard as Markdown. Ideal for use with tools like ChatGPT to search and understand the settings.",
RELEASE_NOTES_NAME: "Display Release Notes after update",
RELEASE_NOTES_DESC:
"<b><u>Toggle ON:</u></b> Display release notes each time you update Excalidraw to a newer version.<br>" +

View File

@@ -25,8 +25,10 @@ export class ReleaseNotes extends Modal {
async onClose() {
this.contentEl.empty();
await this.plugin.loadSettings();
this.plugin.settings.previousRelease = PLUGIN_VERSION
await this.plugin.saveSettings();
if(this.plugin.settings.previousRelease !== PLUGIN_VERSION) {
this.plugin.settings.previousRelease = PLUGIN_VERSION;
await this.plugin.saveSettings();
}
}
async createForm() {
@@ -39,7 +41,8 @@ export class ReleaseNotes extends Modal {
.slice(0, 10)
.join("\n\n---\n")
: FIRST_RUN;
await MarkdownRenderer.renderMarkdown(
await MarkdownRenderer.render(
this.app,
message,
this.contentEl,
"",

View File

@@ -1,80 +1,28 @@
import { MarkdownRenderer, Modal, Notice, request } from "obsidian";
import ExcalidrawPlugin from "../../core/main";
import { errorlog, escapeRegExp } from "../../utils/utils";
import { errorlog } from "../../utils/utils";
import { log } from "src/utils/debugHelper";
import { ContentSearcher } from "../components/ContentSearcher";
const URL =
"https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/ea-scripts/index-new.md";
export class ScriptInstallPrompt extends Modal {
private contentDiv: HTMLDivElement;
constructor(private plugin: ExcalidrawPlugin) {
super(plugin.app);
// this.titleEl.setText(t("INSTAL_MODAL_TITLE"));
}
async onOpen(): Promise<void> {
const searchBarWrapper = document.createElement("div");
searchBarWrapper.classList.add('search-bar-wrapper');
const searchBar = document.createElement("input");
searchBar.type = "text";
searchBar.id = "search-bar";
searchBar.placeholder = "Search...";
//searchBar.style.width = "calc(100% - 120px)"; // space for the buttons and hit count
const nextButton = document.createElement("button");
nextButton.textContent = "→";
nextButton.onclick = () => this.navigateSearchResults("next");
const prevButton = document.createElement("button");
prevButton.textContent = "←";
prevButton.onclick = () => this.navigateSearchResults("previous");
const hitCount = document.createElement("span");
hitCount.id = "hit-count";
hitCount.classList.add('hit-count');
searchBarWrapper.appendChild(prevButton);
searchBarWrapper.appendChild(nextButton);
searchBarWrapper.appendChild(searchBar);
searchBarWrapper.appendChild(hitCount);
this.contentEl.prepend(searchBarWrapper);
searchBar.addEventListener("input", (e) => {
this.clearHighlights();
const searchTerm = (e.target as HTMLInputElement).value;
if (searchTerm && searchTerm.length > 0) {
this.highlightSearchTerm(searchTerm);
const totalHits = this.contentDiv.querySelectorAll("mark.search-highlight").length;
hitCount.textContent = totalHits > 0 ? `1/${totalHits}` : "";
setTimeout(()=>this.navigateSearchResults("next"));
} else {
hitCount.textContent = "";
}
});
searchBar.addEventListener("keydown", (e) => {
// If Ctrl/Cmd + F is pressed, focus on search bar
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
e.preventDefault();
searchBar.focus();
}
// If Enter is pressed, navigate to next result
else if (e.key === "Enter") {
e.preventDefault();
this.navigateSearchResults(e.shiftKey ? "previous" : "next");
}
});
this.contentEl.classList.add("excalidraw-scriptengine-install");
this.contentDiv = document.createElement("div");
this.contentEl.appendChild(this.contentDiv);
const searcher = new ContentSearcher(this.contentDiv);
this.contentEl.prepend(searcher.getSearchBarWrapper());
this.containerEl.classList.add("excalidraw-scriptengine-install");
try {
const source = await request({ url: URL });
@@ -111,99 +59,6 @@ export class ScriptInstallPrompt extends Modal {
}
}
highlightSearchTerm(searchTerm: string): void {
// Create a walker to traverse text nodes
const walker = document.createTreeWalker(
this.contentDiv,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node: Text) => {
return node.nodeValue!.toLowerCase().includes(searchTerm.toLowerCase()) ?
NodeFilter.FILTER_ACCEPT :
NodeFilter.FILTER_REJECT;
}
}
);
const nodesToReplace: Text[] = [];
while (walker.nextNode()) {
nodesToReplace.push(walker.currentNode as Text);
}
nodesToReplace.forEach(node => {
const nodeContent = node.nodeValue!;
const newNode = document.createDocumentFragment();
let lastIndex = 0;
let match;
const regex = new RegExp(escapeRegExp(searchTerm), 'gi');
// Iterate over all matches in the text node
while ((match = regex.exec(nodeContent)) !== null) {
const before = document.createTextNode(nodeContent.slice(lastIndex, match.index));
const highlighted = document.createElement('mark');
highlighted.className = 'search-highlight';
highlighted.textContent = match[0];
highlighted.classList.add('search-result');
newNode.appendChild(before);
newNode.appendChild(highlighted);
lastIndex = regex.lastIndex;
}
newNode.appendChild(document.createTextNode(nodeContent.slice(lastIndex)));
node.replaceWith(newNode);
});
}
clearHighlights(): void {
this.contentDiv.querySelectorAll("mark.search-highlight").forEach((el) => {
el.outerHTML = el.innerHTML;
});
}
navigateSearchResults(direction: "next" | "previous"): void {
const highlights: HTMLElement[] = Array.from(
this.contentDiv.querySelectorAll("mark.search-highlight")
);
if (highlights.length === 0) return;
const currentActiveIndex = highlights.findIndex((highlight) =>
highlight.classList.contains("active-highlight")
);
if (currentActiveIndex !== -1) {
highlights[currentActiveIndex].classList.remove("active-highlight");
highlights[currentActiveIndex].style.border = "none";
}
let nextActiveIndex = 0;
if (direction === "next") {
nextActiveIndex =
currentActiveIndex === highlights.length - 1
? 0
: currentActiveIndex + 1;
} else if (direction === "previous") {
nextActiveIndex =
currentActiveIndex === 0
? highlights.length - 1
: currentActiveIndex - 1;
}
const nextActiveHighlight = highlights[nextActiveIndex];
nextActiveHighlight.classList.add("active-highlight");
nextActiveHighlight.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
// Update the hit count
const hitCount = document.getElementById("hit-count");
hitCount.textContent = `${nextActiveIndex + 1}/${highlights.length}`;
}
onClose(): void {
this.contentEl.empty();
}

View File

@@ -0,0 +1,250 @@
import { t } from "src/lang/helpers";
import { escapeRegExp } from "../../utils/utils";
// @ts-ignore
import { getIcon, htmlToMarkdown, Notice } from "obsidian";
export class ContentSearcher {
private contentDiv: HTMLElement;
private searchBar: HTMLInputElement;
private prevButton: HTMLButtonElement;
private nextButton: HTMLButtonElement;
private exportMarkdown: HTMLButtonElement;
private hitCount: HTMLSpanElement;
private searchBarWrapper: HTMLDivElement;
constructor(contentDiv: HTMLElement) {
this.contentDiv = contentDiv;
this.createSearchElements();
this.setupEventListeners();
}
/**
* Creates search UI elements styled like Obsidian's native search
*/
private createSearchElements(): void {
// Outer container
this.searchBarWrapper = document.createElement("div");
this.searchBarWrapper.classList.add("document-search-container");
// Main search bar
const documentSearch = document.createElement("div");
documentSearch.classList.add("document-search");
// Search input container
const inputContainer = document.createElement("div");
inputContainer.classList.add("search-input-container", "document-search-input");
// Search input
this.searchBar = document.createElement("input");
this.searchBar.type = "text";
this.searchBar.placeholder = "Find...";
// Hit count
this.hitCount = document.createElement("div");
this.hitCount.classList.add("document-search-count");
inputContainer.appendChild(this.searchBar);
inputContainer.appendChild(this.hitCount);
// Search buttons (prev/next)
const buttonContainer = document.createElement("div");
buttonContainer.classList.add("document-search-buttons");
this.prevButton = document.createElement("button");
this.prevButton.classList.add("clickable-icon", "document-search-button");
this.prevButton.setAttribute("aria-label", t("SEARCH_PREVIOUS"));
this.prevButton.setAttribute("data-tooltip-position", "top");
this.prevButton.type = "button";
this.prevButton.innerHTML = getIcon("arrow-up").outerHTML;
this.nextButton = document.createElement("button");
this.nextButton.classList.add("clickable-icon", "document-search-button");
this.nextButton.setAttribute("aria-label", t("SEARCH_NEXT"));
this.nextButton.setAttribute("data-tooltip-position", "top");
this.nextButton.type = "button";
this.nextButton.innerHTML = getIcon("arrow-down").outerHTML;
this.exportMarkdown = document.createElement("button");
this.exportMarkdown.classList.add("clickable-icon", "document-search-button");
this.exportMarkdown.setAttribute("aria-label", t("SEARCH_COPY_TO_CLIPBOARD_ARIA"));
this.exportMarkdown.setAttribute("data-tooltip-position", "top");
this.exportMarkdown.type = "button";
this.exportMarkdown.innerHTML = getIcon("clipboard-copy").outerHTML;
buttonContainer.appendChild(this.prevButton);
buttonContainer.appendChild(this.nextButton);
buttonContainer.appendChild(this.exportMarkdown);
documentSearch.appendChild(inputContainer);
documentSearch.appendChild(buttonContainer);
this.searchBarWrapper.appendChild(documentSearch);
}
/**
* Attach event listeners to search elements
*/
private setupEventListeners(): void {
this.nextButton.onclick = () => this.navigateSearchResults("next");
this.prevButton.onclick = () => this.navigateSearchResults("previous");
this.exportMarkdown.onclick = () => {
// Get the full HTML content first
const fullHtml = this.contentDiv.outerHTML;
// Find the index of the first <hr> element
const startIndex = fullHtml.indexOf('<hr');
// Extract HTML from the first <hr> element onwards
const html = startIndex > -1 ? fullHtml.substring(startIndex) : fullHtml;
function replaceHeading(html:string,level:number):string {
const re = new RegExp(`<summary class="excalidraw-setting-h${level}">([^<]+)<\/summary>`,"g");
return html.replaceAll(re,`<summary class="excalidraw-setting-h${level}"><h${level}>$1</h${level}></summary>`);
}
let x = replaceHeading(html,1);
x = replaceHeading(x,2);
x = replaceHeading(x,3);
x = replaceHeading(x,4);
x = x.replaceAll(/<div class="setting-item-name">([^<]+)<\/div>/g,"<h5>$1</h5>");
const md = htmlToMarkdown(x);
window.navigator.clipboard.writeText(md);
new Notice(t("SEARCH_COPIED_TO_CLIPBOARD"));
};
this.searchBar.addEventListener("input", (e) => {
this.clearHighlights();
const searchTerm = (e.target as HTMLInputElement).value;
if (searchTerm && searchTerm.length > 0) {
this.highlightSearchTerm(searchTerm);
const totalHits = this.contentDiv.querySelectorAll("mark.search-highlight").length;
this.hitCount.textContent = totalHits > 0 ? `1 / ${totalHits}` : "";
setTimeout(() => this.navigateSearchResults("next"));
} else {
this.hitCount.textContent = "";
}
});
this.searchBar.addEventListener("keydown", (e) => {
// If Ctrl/Cmd + F is pressed, focus on search bar
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
e.preventDefault();
this.searchBar.focus();
}
// If Enter is pressed, navigate to next result
else if (e.key === "Enter") {
e.preventDefault();
this.navigateSearchResults(e.shiftKey ? "previous" : "next");
}
});
}
/**
* Get the search bar wrapper element to add to the DOM
*/
public getSearchBarWrapper(): HTMLElement {
return this.searchBarWrapper;
}
/**
* Highlight all instances of the search term in the content
*/
public highlightSearchTerm(searchTerm: string): void {
// Create a walker to traverse text nodes
const walker = document.createTreeWalker(
this.contentDiv,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node: Text) => {
return node.nodeValue!.toLowerCase().includes(searchTerm.toLowerCase()) ?
NodeFilter.FILTER_ACCEPT :
NodeFilter.FILTER_REJECT;
}
}
);
const nodesToReplace: Text[] = [];
while (walker.nextNode()) {
nodesToReplace.push(walker.currentNode as Text);
}
nodesToReplace.forEach(node => {
const nodeContent = node.nodeValue!;
const newNode = document.createDocumentFragment();
let lastIndex = 0;
let match;
const regex = new RegExp(escapeRegExp(searchTerm), 'gi');
// Iterate over all matches in the text node
while ((match = regex.exec(nodeContent)) !== null) {
const before = document.createTextNode(nodeContent.slice(lastIndex, match.index));
const highlighted = document.createElement('mark');
highlighted.className = 'search-highlight';
highlighted.textContent = match[0];
highlighted.classList.add('search-result');
newNode.appendChild(before);
newNode.appendChild(highlighted);
lastIndex = regex.lastIndex;
}
newNode.appendChild(document.createTextNode(nodeContent.slice(lastIndex)));
node.replaceWith(newNode);
});
}
/**
* Remove all search highlights
*/
public clearHighlights(): void {
this.contentDiv.querySelectorAll("mark.search-highlight").forEach((el) => {
el.outerHTML = el.innerHTML;
});
}
/**
* Navigate to next or previous search result
*/
public navigateSearchResults(direction: "next" | "previous"): void {
const highlights: HTMLElement[] = Array.from(
this.contentDiv.querySelectorAll("mark.search-highlight")
);
if (highlights.length === 0) return;
const currentActiveIndex = highlights.findIndex((highlight) =>
highlight.classList.contains("active-highlight")
);
if (currentActiveIndex !== -1) {
highlights[currentActiveIndex].classList.remove("active-highlight");
highlights[currentActiveIndex].style.border = "none";
}
let nextActiveIndex = 0;
if (direction === "next") {
nextActiveIndex =
currentActiveIndex === highlights.length - 1
? 0
: currentActiveIndex + 1;
} else if (direction === "previous") {
nextActiveIndex =
currentActiveIndex === 0
? highlights.length - 1
: currentActiveIndex - 1;
}
const nextActiveHighlight = highlights[nextActiveIndex];
nextActiveHighlight.classList.add("active-highlight");
nextActiveHighlight.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
// Update the hit count
this.hitCount.textContent = `${nextActiveIndex + 1} / ${highlights.length}`;
}
}

View File

@@ -431,25 +431,6 @@ div.excalidraw-draginfo {
margin: auto;
}
.modal-content.excalidraw-scriptengine-install .search-bar-wrapper {
position: sticky;
top: 1rem;
margin-right: 1rem;
display: flex;
align-items: center;
gap: 5px;
flex-wrap: nowrap;
z-index: 10;
background: var(--background-secondary);
padding: 0.5rem;
border-bottom: 1px solid var(--background-modifier-border);
float: right;
max-width: 28rem;
}
div.search-bar-wrapper input {
margin-right: -0.5rem;
}
.modal-content.excalidraw-scriptengine-install .hit-count {
margin-left: 0.5em;
@@ -640,7 +621,7 @@ textarea.excalidraw-wysiwyg, .excalidraw input {
caret-color: var(--excalidraw-caret-color);
}
.excalidraw-settings-links-container, .ex-setting-actions-container {
.excalidraw-settings-links-container {
display: flex; /* Align SVG and text horizontally */
align-items: center; /* Center SVG and text vertically */
text-decoration: none; /* Remove underline from links */
@@ -649,13 +630,7 @@ textarea.excalidraw-wysiwyg, .excalidraw input {
gap: 0.3em;
}
.excalidraw-settings-links-container {
background-color: color-mix(in srgb, var(--color-base-100) 7%, transparent);
padding: 0.5em;
}
.excalidraw-settings-links-container a,
.ex-setting-actions-container a {
.excalidraw-settings-links-container a {
display: flex; /* Align children horizontally */
align-items: center; /* Center items vertically */
text-align: left;
@@ -784,3 +759,96 @@ textarea.excalidraw-wysiwyg, .excalidraw input {
.excalidraw-prompt-buttonbar-bottom > div:last-child {
margin-left: auto;
}
.document-search-container {
display: flex;
flex-direction: column;
background: var(--background-secondary);
border-radius: 8px;
box-shadow: 0 1px 4px 0 rgba(0,0,0,0.10);
padding: 0.5em 0.8em;
margin-bottom: 2em;
min-width: 18rem;
position: sticky;
top: 1rem;
margin-right: 1rem;
margin-left: 1rem;
}
.document-search {
align-items: center;
max-width: none;
}
.search-input-container.document-search-input {
display: flex;
align-items: center;
flex: 1 1 auto;
background: var(--background-primary);
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
min-width: 0;
}
.search-input-container .clickable-icon {
display: flex;
align-items: center;
color: var(--text-faint);
}
.search-input-container input[type="text"] {
background: transparent;
border: none;
outline: none;
color: var(--text-normal);
font-size: 1em;
flex: 1 1 auto;
padding: 0.1em 2em;
margin: 0;
}
.document-search-count {
margin-left: 0.5em;
color: var(--text-faint);
font-size: 0.95em;
white-space: nowrap;
min-width: 3.5em;
text-align: right;
}
.document-search-buttons {
display: flex;
align-items: center;
gap: 2px;
}
.document-search-button {
background: none;
border: none;
outline: none;
box-shadow: none;
padding: 0.1em 0.2em;
margin: 0 1px;
border-radius: 4px;
cursor: pointer;
color: var(--text-faint);
transition: background 0.15s;
height: 2em;
width: 2em;
display: flex;
align-items: center;
justify-content: center;
}
.document-search-button:hover, .document-search-button:focus {
background: var(--background-modifier-hover);
color: var(--text-accent);
}
.document-search-button svg {
width: 1.3em;
height: 1.3em;
stroke: currentColor;
fill: none;
pointer-events: none;
}