diff --git a/src/public/app/layouts/desktop_layout.js b/src/public/app/layouts/desktop_layout.js index 62e6b3ed1..e5c440cdc 100644 --- a/src/public/app/layouts/desktop_layout.js +++ b/src/public/app/layouts/desktop_layout.js @@ -44,6 +44,7 @@ import BacklinksWidget from "../widgets/floating_buttons/zpetne_odkazy.js"; import SharedInfoWidget from "../widgets/shared_info.js"; import FindWidget from "../widgets/find.js"; import TocWidget from "../widgets/toc.js"; +import HighlightedTextWidget from "../widgets/highlighted_text.js"; import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js"; import AboutDialog from "../widgets/dialogs/about.js"; import HelpDialog from "../widgets/dialogs/help.js"; @@ -184,6 +185,7 @@ export default class DesktopLayout { ) .child(new RightPaneContainer() .child(new TocWidget()) + .child(new HighlightedTextWidget()) .child(...this.customWidgets.get('right-pane')) ) ) diff --git a/src/public/app/widgets/highlighted_text.js b/src/public/app/widgets/highlighted_text.js new file mode 100644 index 000000000..daced39d4 --- /dev/null +++ b/src/public/app/widgets/highlighted_text.js @@ -0,0 +1,255 @@ +/** + * 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 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"; + +const TPL = `
+ + + +
`; + +export default class HighlightedTextWidget extends RightPanelWidget { + constructor() { + super(); + + this.closeHltButton = new CloseHltButton(); + this.child(this.closeHltButton); + } + + get widgetTitle() { + return "Highlighted Text"; + } + + isEnabled() { + return super.isEnabled() + && this.note.type === 'text' + && !this.noteContext.viewScope.highlightedTextTemporarilyHidden + && this.noteContext.viewScope.viewMode === 'default'; + } + + async doRenderBody() { + this.$body.empty().append($(TPL)); + this.$hlt = this.$body.find('.highlighted-text'); + this.$body.find('.highlighted-text-widget').append(this.closeHltButton.render()); + } + + async refreshWithNote(note) { + /*The reason for adding highlightedTextPreviousVisible is to record whether the previous state of the highlightedText 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.highlightedTextPreviousVisible == true) { + this.toggleInt(true); + } else { + this.toggleInt(false); + } + const hltLabel = note.getLabel('hideHighlightWidget'); + + const optionsHlt = JSON.parse(options.get('highlightedText')); + + if (hltLabel?.value == "" || hltLabel?.value === "true" || optionsHlt == "") { + this.toggleInt(false); + this.triggerCommand("reEvaluateRightPaneVisibility"); + return; + } + + let $hlt = "", hltLiCount = -1; + // Check for type text unconditionally in case alwaysShowWidget is set + if (this.note.type === 'text') { + const { content } = await note.getNoteComplement(); + ({ $hlt, hltLiCount } = await this.getHlt(content, optionsHlt)); + } + this.$hlt.html($hlt); + if ([undefined, "false"].includes(hltLabel?.value) && hltLiCount > 0) { + this.toggleInt(true); + this.noteContext.viewScope.highlightedTextPreviousVisible = true; + } else { + this.toggleInt(false); + this.noteContext.viewScope.highlightedTextPreviousVisible = false; + } + + this.triggerCommand("reEvaluateRightPaneVisibility"); + } + + /** + * Builds a table of helight text. + */ + getHlt(html, optionsHlt) { + // matches a span containing background-color + const regex1 = /]*style\s*=\s*[^>]*background-color:[^>]*?>[\s\S]*?<\/span>/gi; + // matches a span containing color + const regex2 = /]*style\s*=\s*[^>]*[^-]color:[^>]*?>[\s\S]*?<\/span>/gi; + // match italics + const regex3 = /[\s\S]*?<\/i>/gi; + // match bold + const regex4 = /[\s\S]*?<\/strong>/gi; + // match underline + const regex5 = /[\s\S]*?<\/u>/g; + // Possible values in optionsHlt: '["bold","italic","underline","color","bgColor"]' + // element priority: span>i>strong>u + let findSubStr="", combinedRegexStr = ""; + if (optionsHlt.indexOf("bgColor") >= 0){ + findSubStr+=`,span[style*="background-color"]`; + combinedRegexStr+=`|${regex1.source}`; + } + if (optionsHlt.indexOf("color") >= 0){ + findSubStr+=`,span[style*="color"]`; + combinedRegexStr+=`|${regex2.source}`; + } + if (optionsHlt.indexOf("italic") >= 0){ + findSubStr+=`,i`; + combinedRegexStr+=`|${regex3.source}`; + } + if (optionsHlt.indexOf("bold") >= 0){ + findSubStr+=`,strong`; + combinedRegexStr+=`|${regex4.source}`; + } + if (optionsHlt.indexOf("underline") >= 0){ + findSubStr+=`,u`; + combinedRegexStr+=`|${regex5.source}`; + } + + findSubStr = findSubStr.substring(1) + combinedRegexStr = `(` + combinedRegexStr.substring(1) + `)`; + const combinedRegex = new RegExp(combinedRegexStr, 'gi'); + let $hlt = $("
    "); + let prevEndIndex = -1, hltLiCount = 0; + for (let match = null, hltIndex=0; ((match = combinedRegex.exec(html)) !== null); hltIndex++) { + var subHtml = match[0]; + const startIndex = match.index; + const endIndex = combinedRegex.lastIndex; + if (prevEndIndex != -1 && startIndex === prevEndIndex) { + //If the previous element is connected to this element in HTML, then concatenate them into one. + $hlt.children().last().append(subHtml); + } else { + //hide li if its text content is empty + if ([...subHtml.matchAll(/(?<=^|>)[^><]+?(?=<|$)/g)].map(matchTmp => matchTmp[0]).join('').trim() != ""){ + var $li = $('
  1. '); + $li.html(subHtml); + $li.on("click", () => this.jumpToHlt(findSubStr,hltIndex)); + $hlt.append($li); + hltLiCount++; + }else{ + continue + } + } + prevEndIndex = endIndex; + } + return { + $hlt, + hltLiCount + }; + } + async jumpToHlt(findSubStr,hltIndex) { + 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; + return $(this).prop('tagName')=="SPAN" && color==""?false:true; + }else{ + return true; + } + }).filter(function() { + return $(this).parent(findSubStr).length === 0 + && $(this).parent().parent(findSubStr).length === 0 + && $(this).parent().parent().parent(findSubStr).length === 0 + && $(this).parent().parent().parent().parent(findSubStr).length === 0; + }) + } else { + const textEditor = await this.noteContext.getTextEditor(); + 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 + if (findSubStr.indexOf("color")>=0 && findSubStr.indexOf("background-color")<0){ + let color = this.style.color; + return $(this).prop('tagName')=="SPAN" && color==""?false:true; + }else{ + return true; + } + }).filter(function() { + //Need to filter out the child elements of the element that has been found + return $(this).parent(findSubStr).length === 0 + && $(this).parent().parent(findSubStr).length === 0 + && $(this).parent().parent().parent(findSubStr).length === 0 + && $(this).parent().parent().parent().parent(findSubStr).length === 0; + }) + } + targetElement[hltIndex].scrollIntoView({ + behavior: "smooth", block: "center" + }); + } + + async closeHltCommand() { + this.noteContext.viewScope.highlightedTextTemporarilyHidden = true; + await this.refresh(); + this.triggerCommand('reEvaluateRightPaneVisibility'); + } + + async entitiesReloadedEvent({ loadResults }) { + if (loadResults.isNoteContentReloaded(this.noteId)) { + await this.refresh(); + } else if (loadResults.getAttributes().find(attr => attr.type === 'label' + && (attr.name.toLowerCase().includes('readonly') || attr.name === 'hideHighlightWidget') + && attributeService.isAffecting(attr, this.note))) { + await this.refresh(); + } + } +} + + +class CloseHltButton extends OnClickButtonWidget { + constructor() { + super(); + + this.icon("bx-x") + .title("Close HighlightedTextWidget") + .titlePlacement("bottom") + .onClick((widget, e) => { + e.stopPropagation(); + + widget.triggerCommand("closeHlt"); + }) + .class("icon-action close-highlighted-text"); + } +} diff --git a/src/public/app/widgets/toc.js b/src/public/app/widgets/toc.js index 5bdd4358c..8d66b3294 100644 --- a/src/public/app/widgets/toc.js +++ b/src/public/app/widgets/toc.js @@ -38,6 +38,10 @@ const TPL = `
    .toc li { cursor: pointer; + text-align: justify; + text-justify: distribute; + word-wrap: break-word; + hyphens: auto; } .toc li:hover { @@ -80,6 +84,16 @@ export default class TocWidget extends RightPanelWidget { } async refreshWithNote(note) { + /*The reason for adding tocPreviousVisible is to record whether the previous state of the toc 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 highlighttext but not toc, every time the note content is changed, + * toc will appear and then close immediately, because getToc(html) function will consume time*/ + if (this.noteContext.viewScope.tocPreviousVisible ==true){ + this.toggleInt(true); + }else{ + this.toggleInt(false); + } + const tocLabel = note.getLabel('toc'); if (tocLabel?.value === 'hide') { @@ -96,10 +110,13 @@ export default class TocWidget extends RightPanelWidget { } this.$toc.html($toc); - this.toggleInt( - ["", "show"].includes(tocLabel?.value) - || headingCount >= options.getInt('minTocHeadings') - ); + if (["", "show"].includes(tocLabel?.value) || headingCount >= options.getInt('minTocHeadings')){ + this.toggleInt(true); + this.noteContext.viewScope.tocPreviousVisible=true; + }else{ + this.toggleInt(false); + this.noteContext.viewScope.tocPreviousVisible=false; + } this.triggerCommand("reEvaluateRightPaneVisibility"); } diff --git a/src/public/app/widgets/type_widgets/content_widget.js b/src/public/app/widgets/type_widgets/content_widget.js index 967c996e5..f7a4846bd 100644 --- a/src/public/app/widgets/type_widgets/content_widget.js +++ b/src/public/app/widgets/type_widgets/content_widget.js @@ -7,6 +7,7 @@ import MaxContentWidthOptions from "./options/appearance/max_content_width.js"; import KeyboardShortcutsOptions from "./options/shortcuts.js"; import HeadingStyleOptions from "./options/text_notes/heading_style.js"; import TableOfContentsOptions from "./options/text_notes/table_of_contents.js"; +import HighlightedTextOptions from "./options/text_notes/highlighted_text.js"; import TextAutoReadOnlySizeOptions from "./options/text_notes/text_auto_read_only_size.js"; import VimKeyBindingsOptions from "./options/code_notes/vim_key_bindings.js"; import WrapLinesOptions from "./options/code_notes/wrap_lines.js"; @@ -61,6 +62,7 @@ const CONTENT_WIDGETS = { _optionsTextNotes: [ HeadingStyleOptions, TableOfContentsOptions, + HighlightedTextOptions, TextAutoReadOnlySizeOptions ], _optionsCodeNotes: [ diff --git a/src/public/app/widgets/type_widgets/options/text_notes/highlighted_text.js b/src/public/app/widgets/type_widgets/options/text_notes/highlighted_text.js new file mode 100644 index 000000000..4f96999cc --- /dev/null +++ b/src/public/app/widgets/type_widgets/options/text_notes/highlighted_text.js @@ -0,0 +1,38 @@ +import OptionsWidget from "../options_widget.js"; + +const TPL = ` +
    +

    Highlighted Text

    + + You can customize the highlighted text displayed in the right panel:
    + + + + + + +
    `; + +export default class HighlightedTextOptions extends OptionsWidget { + doRender() { + this.$widget = $(TPL); + this.$hlt = this.$widget.find("input.highlighted-text-check"); + this.$hlt.on('change', () => { + const hltVals=this.$widget.find('input.highlighted-text-check[type="checkbox"]:checked').map(function() { + return this.value; + }).get(); + this.updateOption('highlightedText', JSON.stringify(hltVals)); + }); + } + + async optionsLoaded(options) { + const hltVals=JSON.parse(options.highlightedText); + this.$widget.find('input.highlighted-text-check[type="checkbox"]').each(function () { + if ($.inArray($(this).val(), hltVals) !== -1) { + $(this).prop("checked", true); + } else { + $(this).prop("checked", false); + } + }); + } +} diff --git a/src/routes/api/options.js b/src/routes/api/options.js index e98b1795f..4d9ee77a2 100644 --- a/src/routes/api/options.js +++ b/src/routes/api/options.js @@ -60,6 +60,7 @@ const ALLOWED_OPTIONS = new Set([ 'compressImages', 'downloadImagesAutomatically', 'minTocHeadings', + 'highlightedText', 'checkForUpdates', 'disableTray', 'customSearchEngineName', diff --git a/src/services/options_init.js b/src/services/options_init.js index 1ce5bbd5d..358403214 100644 --- a/src/services/options_init.js +++ b/src/services/options_init.js @@ -87,6 +87,7 @@ const defaultOptions = [ { name: 'compressImages', value: 'true', isSynced: true }, { name: 'downloadImagesAutomatically', value: 'true', isSynced: true }, { name: 'minTocHeadings', value: '5', isSynced: true }, + { name: 'highlightedText', value: '["bold","italic","underline","color","bgColor"]', isSynced: true }, { name: 'checkForUpdates', value: 'true', isSynced: true }, { name: 'disableTray', value: 'false', isSynced: false }, { name: 'customSearchEngineName', value: 'Duckduckgo', isSynced: false },