mirror of
https://github.com/zadam/trilium.git
synced 2025-11-11 15:55:52 +01:00
chore(monorepo/client): move client source files
This commit is contained in:
392
apps/client/src/widgets/highlights_list.ts
Normal file
392
apps/client/src/widgets/highlights_list.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Widget: Show highlighted text in the right pane
|
||||
*
|
||||
* By design, there's no support for nonsensical or malformed constructs:
|
||||
* - For example, if there is a formula in the middle of the highlighted text, the two ends of the formula will be regarded as two entries
|
||||
*/
|
||||
|
||||
import { t } from "../services/i18n.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
import RightPanelWidget from "./right_panel_widget.js";
|
||||
import options from "../services/options.js";
|
||||
import OnClickButtonWidget from "./buttons/onclick_button.js";
|
||||
import appContext, { type EventData } from "../components/app_context.js";
|
||||
import libraryLoader from "../services/library_loader.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
|
||||
const TPL = /*html*/`<div class="highlights-list-widget">
|
||||
<style>
|
||||
.highlights-list-widget {
|
||||
padding: 10px;
|
||||
contain: none;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.highlights-list > ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.highlights-list li {
|
||||
cursor: pointer;
|
||||
margin-bottom: 3px;
|
||||
text-align: justify;
|
||||
word-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.highlights-list li:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
<span class="highlights-list"></span>
|
||||
</div>`;
|
||||
|
||||
export default class HighlightsListWidget extends RightPanelWidget {
|
||||
private $highlightsList!: JQuery<HTMLElement>;
|
||||
|
||||
get widgetTitle() {
|
||||
return t("highlights_list_2.title");
|
||||
}
|
||||
|
||||
get widgetButtons() {
|
||||
return [
|
||||
new OnClickButtonWidget()
|
||||
.icon("bx-cog")
|
||||
.title(t("highlights_list_2.options"))
|
||||
.titlePlacement("left")
|
||||
.onClick(() => appContext.tabManager.openContextWithNote("_optionsTextNotes", { activate: true }))
|
||||
.class("icon-action"),
|
||||
new OnClickButtonWidget()
|
||||
.icon("bx-x")
|
||||
.titlePlacement("left")
|
||||
.onClick((widget: OnClickButtonWidget) => widget.triggerCommand("closeHlt"))
|
||||
.class("icon-action")
|
||||
];
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return (
|
||||
super.isEnabled() && this.note != null && this.note.type === "text" && !this.noteContext?.viewScope?.highlightsListTemporarilyHidden && this.noteContext?.viewScope?.viewMode === "default"
|
||||
);
|
||||
}
|
||||
|
||||
async doRenderBody() {
|
||||
this.$body.empty().append($(TPL));
|
||||
this.$highlightsList = this.$body.find(".highlights-list");
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote | null | undefined) {
|
||||
/* The reason for adding highlightsListPreviousVisible is to record whether the previous state
|
||||
of the highlightsList is hidden or displayed, and then let it be displayed/hidden at the initial time.
|
||||
If there is no such value, when the right panel needs to display toc but not highlighttext,
|
||||
every time the note content is changed, highlighttext Widget will appear and then close immediately,
|
||||
because getHlt function will consume time */
|
||||
if (this.noteContext?.viewScope?.highlightsListPreviousVisible) {
|
||||
this.toggleInt(true);
|
||||
} else {
|
||||
this.toggleInt(false);
|
||||
}
|
||||
|
||||
const optionsHighlightsList = JSON.parse(options.get("highlightsList"));
|
||||
|
||||
if (note?.isLabelTruthy("hideHighlightWidget") || !optionsHighlightsList.length) {
|
||||
this.toggleInt(false);
|
||||
this.triggerCommand("reEvaluateRightPaneVisibility");
|
||||
return;
|
||||
}
|
||||
|
||||
let $highlightsList: JQuery<HTMLElement> | null = null;
|
||||
let hlLiCount = -1;
|
||||
// Check for type text unconditionally in case alwaysShowWidget is set
|
||||
if (note && this.note?.type === "text") {
|
||||
const noteComplement = await note.getNoteComplement();
|
||||
if (noteComplement && "content" in noteComplement) {
|
||||
({ $highlightsList, hlLiCount } = await this.getHighlightList(noteComplement.content, optionsHighlightsList));
|
||||
}
|
||||
}
|
||||
this.$highlightsList.empty();
|
||||
if ($highlightsList) {
|
||||
this.$highlightsList.append($highlightsList);
|
||||
}
|
||||
if (hlLiCount > 0) {
|
||||
this.toggleInt(true);
|
||||
if (this.noteContext?.viewScope) {
|
||||
this.noteContext.viewScope.highlightsListPreviousVisible = true;
|
||||
}
|
||||
} else {
|
||||
this.toggleInt(false);
|
||||
if (this.noteContext?.viewScope) {
|
||||
this.noteContext.viewScope.highlightsListPreviousVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.triggerCommand("reEvaluateRightPaneVisibility");
|
||||
}
|
||||
|
||||
extractOuterTag(htmlStr: string | null) {
|
||||
if (htmlStr === null) {
|
||||
return null;
|
||||
}
|
||||
// Regular expressions that match only the outermost tag
|
||||
const regex = /^<([a-zA-Z]+)([^>]*)>/;
|
||||
const match = htmlStr.match(regex);
|
||||
if (match) {
|
||||
const tagName = match[1].toLowerCase(); // Extract tag name
|
||||
const attributes = match[2].trim(); // Extract label attributes
|
||||
return { tagName, attributes };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
areOuterTagsConsistent(str1: string | null, str2: string | null) {
|
||||
const tag1 = this.extractOuterTag(str1);
|
||||
const tag2 = this.extractOuterTag(str2);
|
||||
// If one of them has no label, returns false
|
||||
if (!tag1 || !tag2) {
|
||||
return false;
|
||||
}
|
||||
// Compare tag names and attributes to see if they are the same
|
||||
return tag1.tagName === tag2.tagName && tag1.attributes === tag2.attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendering formulas in strings using katex
|
||||
*
|
||||
* @param html Note's html content
|
||||
* @returns The HTML content with mathematical formulas rendered by KaTeX.
|
||||
*/
|
||||
async replaceMathTextWithKatax(html: string) {
|
||||
const mathTextRegex = /<span class="math-tex">\\\(([\s\S]*?)\\\)<\/span>/g;
|
||||
var matches = [...html.matchAll(mathTextRegex)];
|
||||
let modifiedText = html;
|
||||
|
||||
if (matches.length > 0) {
|
||||
// Process all matches asynchronously
|
||||
for (const match of matches) {
|
||||
let latexCode = match[1];
|
||||
let rendered;
|
||||
|
||||
try {
|
||||
rendered = katex.renderToString(latexCode, {
|
||||
throwOnError: false
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof ReferenceError && e.message.includes("katex is not defined")) {
|
||||
// Load KaTeX if it is not already loaded
|
||||
await libraryLoader.requireLibrary(libraryLoader.KATEX);
|
||||
try {
|
||||
rendered = katex.renderToString(latexCode, {
|
||||
throwOnError: false
|
||||
});
|
||||
} catch (renderError) {
|
||||
console.error("KaTeX rendering error after loading library:", renderError);
|
||||
rendered = match[0]; // Fall back to original if error persists
|
||||
}
|
||||
} else {
|
||||
console.error("KaTeX rendering error:", e);
|
||||
rendered = match[0]; // Fall back to original on error
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the matched formula in the modified text
|
||||
modifiedText = modifiedText.replace(match[0], rendered);
|
||||
}
|
||||
}
|
||||
return modifiedText;
|
||||
}
|
||||
|
||||
async getHighlightList(content: string, optionsHighlightsList: string[]) {
|
||||
// matches a span containing background-color
|
||||
const regex1 = /<span[^>]*style\s*=\s*[^>]*background-color:[^>]*?>[\s\S]*?<\/span>/gi;
|
||||
// matches a span containing color
|
||||
const regex2 = /<span[^>]*style\s*=\s*[^>]*[^-]color:[^>]*?>[\s\S]*?<\/span>/gi;
|
||||
// match italics
|
||||
const regex3 = /(<i>[\s\S]*?<\/i>|<em>[\s\S]*?<\/em>)/gi;
|
||||
// match bold
|
||||
const regex4 = /<strong>[\s\S]*?<\/strong>/gi;
|
||||
// match underline
|
||||
const regex5 = /<u>[\s\S]*?<\/u>/g;
|
||||
// Possible values in optionsHighlightsList: '["bold","italic","underline","color","bgColor"]'
|
||||
// element priority: span>i>strong>u
|
||||
let findSubStr = "",
|
||||
combinedRegexStr = "";
|
||||
if (optionsHighlightsList.includes("bgColor")) {
|
||||
findSubStr += `,span[style*="background-color"]:not(section.include-note span[style*="background-color"])`;
|
||||
combinedRegexStr += `|${regex1.source}`;
|
||||
}
|
||||
if (optionsHighlightsList.includes("color")) {
|
||||
findSubStr += `,span[style*="color"]:not(section.include-note span[style*="color"])`;
|
||||
combinedRegexStr += `|${regex2.source}`;
|
||||
}
|
||||
if (optionsHighlightsList.includes("italic")) {
|
||||
findSubStr += `,i:not(section.include-note i)`;
|
||||
findSubStr += `,em:not(section.include-note em)`;
|
||||
combinedRegexStr += `|${regex3.source}`;
|
||||
}
|
||||
if (optionsHighlightsList.includes("bold")) {
|
||||
findSubStr += `,strong:not(section.include-note strong)`;
|
||||
combinedRegexStr += `|${regex4.source}`;
|
||||
}
|
||||
if (optionsHighlightsList.includes("underline")) {
|
||||
findSubStr += `,u:not(section.include-note u)`;
|
||||
combinedRegexStr += `|${regex5.source}`;
|
||||
}
|
||||
|
||||
findSubStr = findSubStr.substring(1);
|
||||
combinedRegexStr = `(` + combinedRegexStr.substring(1) + `)`;
|
||||
const combinedRegex = new RegExp(combinedRegexStr, "gi");
|
||||
const $highlightsList = $("<ol>");
|
||||
let prevEndIndex = -1,
|
||||
hlLiCount = 0;
|
||||
let prevSubHtml: string | null = null;
|
||||
// Used to determine if a string is only a formula
|
||||
const onlyMathRegex = /^<span class="math-tex">\\\([^\)]*?\)<\/span>(?:<span class="math-tex">\\\([^\)]*?\)<\/span>)*$/;
|
||||
|
||||
for (let match = null, hltIndex = 0; (match = combinedRegex.exec(content)) !== null; hltIndex++) {
|
||||
const subHtml = match[0];
|
||||
const startIndex = match.index;
|
||||
const endIndex = combinedRegex.lastIndex;
|
||||
|
||||
// Ignore footnotes.
|
||||
if (subHtml.startsWith('<strong><a href="#fnref')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prevEndIndex !== -1 && startIndex === prevEndIndex) {
|
||||
// If the previous element is connected to this element in HTML, then concatenate them into one.
|
||||
$highlightsList.children().last().append(subHtml);
|
||||
} else {
|
||||
// TODO: can't be done with $(subHtml).text()?
|
||||
//Can’t remember why regular expressions are used here, but modified to $(subHtml).text() works as expected
|
||||
//const hasText = [...subHtml.matchAll(/(?<=^|>)[^><]+?(?=<|$)/g)].map(matchTmp => matchTmp[0]).join('').trim();
|
||||
const hasText = $(subHtml).text().trim();
|
||||
|
||||
if (hasText) {
|
||||
const substring = content.substring(prevEndIndex, startIndex);
|
||||
//If the two elements have the same style and there are only formulas in between, append the formulas and the current element to the end of the previous element.
|
||||
if (this.areOuterTagsConsistent(prevSubHtml, subHtml) && onlyMathRegex.test(substring)) {
|
||||
const $lastLi = $highlightsList.children("li").last();
|
||||
$lastLi.append(await this.replaceMathTextWithKatax(substring));
|
||||
$lastLi.append(subHtml);
|
||||
} else {
|
||||
$highlightsList.append(
|
||||
$("<li>")
|
||||
.html(subHtml)
|
||||
.on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex))
|
||||
);
|
||||
}
|
||||
|
||||
hlLiCount++;
|
||||
} else {
|
||||
// hide li if its text content is empty
|
||||
continue;
|
||||
}
|
||||
}
|
||||
prevEndIndex = endIndex;
|
||||
prevSubHtml = subHtml;
|
||||
}
|
||||
return {
|
||||
$highlightsList,
|
||||
hlLiCount,
|
||||
findSubStr
|
||||
};
|
||||
}
|
||||
|
||||
async jumpToHighlightsList(findSubStr: string, itemIndex: number) {
|
||||
if (!this.noteContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isReadOnly = await this.noteContext.isReadOnly();
|
||||
let targetElement;
|
||||
if (isReadOnly) {
|
||||
const $container = await this.noteContext.getContentElement();
|
||||
targetElement = $container
|
||||
.find(findSubStr)
|
||||
.filter(function () {
|
||||
if (findSubStr.indexOf("color") >= 0 && findSubStr.indexOf("background-color") < 0) {
|
||||
let color = this.style.color;
|
||||
const $el = $(this as HTMLElement);
|
||||
return !($el.prop("tagName") === "SPAN" && color === "");
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.filter(function () {
|
||||
const $el = $(this as HTMLElement);
|
||||
return (
|
||||
$el.parent(findSubStr).length === 0 &&
|
||||
$el.parent().parent(findSubStr).length === 0 &&
|
||||
$el.parent().parent().parent(findSubStr).length === 0 &&
|
||||
$el.parent().parent().parent().parent(findSubStr).length === 0
|
||||
);
|
||||
});
|
||||
} else {
|
||||
const textEditor = await this.noteContext.getTextEditor();
|
||||
if (textEditor) {
|
||||
targetElement = $(textEditor.editing.view.domRoots.values().next().value)
|
||||
.find(findSubStr)
|
||||
.filter(function () {
|
||||
// When finding span[style*="color"] but not looking for span[style*="background-color"],
|
||||
// the background-color error will be regarded as color, so it needs to be filtered
|
||||
const $el = $(this as HTMLElement);
|
||||
if (findSubStr.indexOf("color") >= 0 && findSubStr.indexOf("background-color") < 0) {
|
||||
let color = this.style.color;
|
||||
return !($el.prop("tagName") === "SPAN" && color === "");
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.filter(function () {
|
||||
// Need to filter out the child elements of the element that has been found
|
||||
const $el = $(this as HTMLElement);
|
||||
return (
|
||||
$el.parent(findSubStr).length === 0 &&
|
||||
$el.parent().parent(findSubStr).length === 0 &&
|
||||
$el.parent().parent().parent(findSubStr).length === 0 &&
|
||||
$el.parent().parent().parent().parent(findSubStr).length === 0
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (targetElement && targetElement[itemIndex]) {
|
||||
targetElement[itemIndex].scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center"
|
||||
});
|
||||
} else {
|
||||
console.warn("Unable to find the target element in the highlights list.");
|
||||
}
|
||||
}
|
||||
|
||||
async closeHltCommand() {
|
||||
if (this.noteContext?.viewScope) {
|
||||
this.noteContext.viewScope.highlightsListTemporarilyHidden = true;
|
||||
}
|
||||
await this.refresh();
|
||||
this.triggerCommand("reEvaluateRightPaneVisibility");
|
||||
appContext.triggerEvent("reEvaluateHighlightsListWidgetVisibility", { noteId: this.noteId });
|
||||
}
|
||||
|
||||
async showHighlightsListWidgetEvent({ noteId }: EventData<"showHighlightsListWidget">) {
|
||||
if (this.noteId === noteId) {
|
||||
await this.refresh();
|
||||
this.triggerCommand("reEvaluateRightPaneVisibility");
|
||||
}
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (this.noteId && loadResults.isNoteContentReloaded(this.noteId)) {
|
||||
await this.refresh();
|
||||
} else if (
|
||||
loadResults
|
||||
.getAttributeRows()
|
||||
.find((attr) => attr.type === "label" && (attr.name?.toLowerCase().includes("readonly") || attr.name === "hideHighlightWidget") && attributeService.isAffecting(attr, this.note))
|
||||
) {
|
||||
await this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user