From eefa267fbbdba429e043557513595872bf276b53 Mon Sep 17 00:00:00 2001 From: kotaindah55 Date: Wed, 25 Sep 2024 12:01:47 +0300 Subject: [PATCH] First push --- esbuild.config.mjs | 48 + main.js | 1070 +++++++++++++++++ main.ts | 37 + manifest.json | 9 + package.json | 25 + readme.md | 146 +++ src/declarations/index.d.ts | 60 + src/editorPlugins/alignerPlugin.ts | 71 ++ src/editorPlugins/customHighlightPlugin.ts | 74 ++ .../decorators/alignerDecorators.ts | 28 + .../decorators/formattingDecorators.ts | 9 + src/editorPlugins/decorators/hiddenSpoiler.ts | 5 + .../decorators/highlightDecorator.ts | 6 + src/editorPlugins/decorators/index.ts | 4 + src/editorPlugins/extendedFormattingPlugin.ts | 85 ++ src/editorPlugins/index.ts | 3 + .../stateFields/customHighlightField.ts | 110 ++ .../stateFields/extendedFormattingField.ts | 166 +++ src/editorPlugins/stateFields/index.ts | 2 + src/editorPlugins/widgets/ColorButton.ts | 110 ++ src/editorPlugins/widgets/HiddenWidget.ts | 19 + src/editorPlugins/widgets/index.ts | 2 + src/enums/DelimType.ts | 6 + src/enums/index.ts | 1 + src/interfaces/BlockquoteChildSection.ts | 4 + src/interfaces/index.ts | 1 + src/postProcessor/AlignerPostProcessor.ts | 49 + .../CustomHighlightPostProcessor.ts | 25 + .../ExtendedFormattingPostProcessor.ts | 98 ++ src/postProcessor/index.ts | 3 + src/regExps/delimRegExps.ts | 32 + src/regExps/index.ts | 2 + src/regExps/postProcessorDelimRegExps.ts | 40 + src/types/DelimPos.ts | 1 + src/types/ExtendedElement.ts | 1 + src/types/HighlightProp.ts | 1 + src/types/Pos.ts | 1 + src/types/index.ts | 4 + src/utils/codemirror/checkSelectionOverlap.ts | 17 + src/utils/codemirror/index.ts | 7 + src/utils/codemirror/isCodeblock.ts | 18 + src/utils/codemirror/isColumnSeparator.ts | 5 + src/utils/codemirror/isHighlight.ts | 17 + src/utils/codemirror/isRestrictedPos.ts | 6 + src/utils/codemirror/isRestrictedRange.ts | 27 + src/utils/codemirror/isTable.ts | 16 + src/utils/getTextAtLine.ts | 6 + src/utils/index.ts | 2 + src/utils/postProcess/excludeElements.ts | 27 + .../postProcess/getBlockquoteSections.ts | 106 ++ .../postProcess/getDelimiterFromPreview.ts | 122 ++ src/utils/postProcess/getEscapedDelims.ts | 56 + src/utils/postProcess/getRestrictedRange.ts | 13 + src/utils/postProcess/index.ts | 7 + src/utils/postProcess/iterDelimReplacement.ts | 41 + src/utils/postProcess/splitCells.ts | 20 + src/utils/replaceStringAtPos.ts | 8 + styles.css | 153 +++ tsconfig.json | 17 + 59 files changed, 3049 insertions(+) create mode 100644 esbuild.config.mjs create mode 100644 main.js create mode 100644 main.ts create mode 100644 manifest.json create mode 100644 package.json create mode 100644 readme.md create mode 100644 src/declarations/index.d.ts create mode 100644 src/editorPlugins/alignerPlugin.ts create mode 100644 src/editorPlugins/customHighlightPlugin.ts create mode 100644 src/editorPlugins/decorators/alignerDecorators.ts create mode 100644 src/editorPlugins/decorators/formattingDecorators.ts create mode 100644 src/editorPlugins/decorators/hiddenSpoiler.ts create mode 100644 src/editorPlugins/decorators/highlightDecorator.ts create mode 100644 src/editorPlugins/decorators/index.ts create mode 100644 src/editorPlugins/extendedFormattingPlugin.ts create mode 100644 src/editorPlugins/index.ts create mode 100644 src/editorPlugins/stateFields/customHighlightField.ts create mode 100644 src/editorPlugins/stateFields/extendedFormattingField.ts create mode 100644 src/editorPlugins/stateFields/index.ts create mode 100644 src/editorPlugins/widgets/ColorButton.ts create mode 100644 src/editorPlugins/widgets/HiddenWidget.ts create mode 100644 src/editorPlugins/widgets/index.ts create mode 100644 src/enums/DelimType.ts create mode 100644 src/enums/index.ts create mode 100644 src/interfaces/BlockquoteChildSection.ts create mode 100644 src/interfaces/index.ts create mode 100644 src/postProcessor/AlignerPostProcessor.ts create mode 100644 src/postProcessor/CustomHighlightPostProcessor.ts create mode 100644 src/postProcessor/ExtendedFormattingPostProcessor.ts create mode 100644 src/postProcessor/index.ts create mode 100644 src/regExps/delimRegExps.ts create mode 100644 src/regExps/index.ts create mode 100644 src/regExps/postProcessorDelimRegExps.ts create mode 100644 src/types/DelimPos.ts create mode 100644 src/types/ExtendedElement.ts create mode 100644 src/types/HighlightProp.ts create mode 100644 src/types/Pos.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/codemirror/checkSelectionOverlap.ts create mode 100644 src/utils/codemirror/index.ts create mode 100644 src/utils/codemirror/isCodeblock.ts create mode 100644 src/utils/codemirror/isColumnSeparator.ts create mode 100644 src/utils/codemirror/isHighlight.ts create mode 100644 src/utils/codemirror/isRestrictedPos.ts create mode 100644 src/utils/codemirror/isRestrictedRange.ts create mode 100644 src/utils/codemirror/isTable.ts create mode 100644 src/utils/getTextAtLine.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/postProcess/excludeElements.ts create mode 100644 src/utils/postProcess/getBlockquoteSections.ts create mode 100644 src/utils/postProcess/getDelimiterFromPreview.ts create mode 100644 src/utils/postProcess/getEscapedDelims.ts create mode 100644 src/utils/postProcess/getRestrictedRange.ts create mode 100644 src/utils/postProcess/index.ts create mode 100644 src/utils/postProcess/iterDelimReplacement.ts create mode 100644 src/utils/postProcess/splitCells.ts create mode 100644 src/utils/replaceStringAtPos.ts create mode 100644 styles.css create mode 100644 tsconfig.json diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..b13282b --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,48 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = +`/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = (process.argv[2] === "production"); + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ["main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..362a43a --- /dev/null +++ b/main.js @@ -0,0 +1,1070 @@ +/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ + +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + +// main.ts +var main_exports = {}; +__export(main_exports, { + default: () => ExtendedMarkdownSyntax +}); +module.exports = __toCommonJS(main_exports); +var import_obsidian5 = require("obsidian"); +var import_view10 = require("@codemirror/view"); + +// src/editorPlugins/extendedFormattingPlugin.ts +var import_view7 = require("@codemirror/view"); +var import_obsidian2 = require("obsidian"); + +// src/editorPlugins/stateFields/extendedFormattingField.ts +var import_state = require("@codemirror/state"); +var import_language4 = require("@codemirror/language"); + +// src/utils/codemirror/checkSelectionOverlap.ts +function checkSelectionOverlap(selection, from, to) { + if (!selection) { + return false; + } + for (const range of selection.ranges) { + if (range.to >= from && range.from <= to) { + return true; + } + return false; + } + return false; +} + +// src/utils/codemirror/isCodeblock.ts +var import_language = require("@codemirror/language"); +function isCodeblock(view, from, to) { + let isCodeblock2 = false; + (0, import_language.syntaxTree)(view.state).iterate({ + from, + to, + enter: (node) => { + if (/^inline-code/.test(node.name) || node.name == "HyperMD-codeblock_HyperMD-codeblock-bg") { + isCodeblock2 = true; + return false; + } + } + }); + return isCodeblock2; +} + +// src/utils/codemirror/isHighlight.ts +var import_language2 = require("@codemirror/language"); + +// src/utils/codemirror/isRestrictedRange.ts +var import_language3 = require("@codemirror/language"); + +// src/utils/codemirror/isColumnSeparator.ts +function isColumnSeparator(tree, pos) { + return /hmd-table-sep/.test(tree.resolveInner(pos, 1).name); +} + +// src/utils/codemirror/isRestrictedPos.ts +function isRestrictedPos(tree, pos) { + return /(?:inline-code)|(?:tag)|(?:HyperMD-codeblock)|(?:footref)|(?:hmd-internal-link)|(?:hmd-footnote)|(?:math)|(?:hmd-codeblock)|(?:formatting-strikethrough)/.test(tree.resolveInner(pos, 1).name); +} + +// src/utils/codemirror/isTable.ts +function isTable(tree, pos) { + let isTable2 = false; + tree.iterate({ + from: pos, + to: pos, + enter: (node) => { + if (/table/.test(node.name)) { + isTable2 = true; + return false; + } + } + }); + return isTable2; +} + +// src/regExps/delimRegExps.ts +var delimRegExps = /* @__PURE__ */ new Map([ + ["u" /* U */, { + openingDelim: new RegExp("(?:(?\\n?))", "gd"), + closingDelim: new RegExp("(?\\n?))\\+\\+", "gd"), + raw: { + openingDelim: new RegExp("((?\\n?))", "gd"), + closingDelim: new RegExp("(?\\n?))\\^", "gd"), + raw: { + openingDelim: new RegExp("((?\\n?))", "gd"), + closingDelim: new RegExp("(?\\n?))~", "gd"), + raw: { + openingDelim: new RegExp("((?\\n?))", "gd"), + closingDelim: new RegExp("(?\\n?))\\|\\|", "gd"), + raw: { + openingDelim: new RegExp("((? { + if (from >= delimPos[1]) { + if (from < delimPos[3] || from == delimPos[3] && delimPos[2] == delimPos[3]) { + lastIndex = delimPos[0]; + collection.splice(index); + } else if (from - delimPos[3] < delimLength) { + lastIndex = delimPos[3]; + collection.splice(index + 1); + } else { + collection.splice(index + 1); + } + return true; + } else if (index == 0) { + collection.splice(0); + return true; + } + }); + openingDelim.lastIndex = doubleNewLineChar.lastIndex = lastIndex; + } + mainLoop: for (let openingPos = (_a = openingDelim.exec(text)) == null ? void 0 : _a.indices[0], endLineOffset = (_c = (_b = doubleNewLineChar.exec(text)) == null ? void 0 : _b.index) != null ? _c : text.length; openingPos; openingPos = (_d = openingDelim.exec(text)) == null ? void 0 : _d.indices[0]) { + if (openingPos[1] > tree.length) { + break; + } + if (isRestrictedPos(tree, openingPos[0])) { + continue; + } + let delimPos = [0, 0, 0, 0]; + closingDelim.lastIndex = openingPos[1]; + let endCellOffset = 0, currentLine = doc.lineAt(openingPos[0]), isTableRow = isTable(tree, openingPos[0]); + if (isTableRow) { + if (type == "spoiler") { + openingDelim.lastIndex = currentLine.to; + continue; + } + for (verticalBarChar.lastIndex = openingPos[0] - currentLine.from; ; ) { + let verticalBarPos = (_e = verticalBarChar.exec(currentLine.text)) == null ? void 0 : _e.index; + if (!verticalBarPos) { + endCellOffset = currentLine.to; + break; + } else if (isColumnSeparator(tree, verticalBarPos + currentLine.from)) { + endCellOffset = verticalBarPos + currentLine.from; + break; + } + } + } + if (openingDelim.lastIndex > endLineOffset) { + doubleNewLineChar.lastIndex = openingDelim.lastIndex; + endLineOffset = ((_f = doubleNewLineChar.exec(text)) == null ? void 0 : _f.index) || text.length; + } + [delimPos[0], delimPos[1]] = openingPos; + while (true) { + let closingPos = (_g = closingDelim.exec(text)) == null ? void 0 : _g.indices[0]; + if (!closingPos || closingDelim.lastIndex > endLineOffset || isTableRow && closingDelim.lastIndex > endCellOffset) { + delimPos[2] = delimPos[3] = openingDelim.lastIndex = isTableRow ? endCellOffset : endLineOffset; + closingDelim.lastIndex > endLineOffset && (endLineOffset = ((_h = doubleNewLineChar.exec(text)) == null ? void 0 : _h.index) || text.length); + } else { + if (isRestrictedPos(tree, closingPos[0])) { + continue; + } + openingDelim.lastIndex = closingPos[1]; + [delimPos[2], delimPos[3]] = closingPos; + } + break; + } + delimPosCollection.push(delimPos); + } + closingDelim.lastIndex && (closingDelim.lastIndex = 0); + return delimPosCollection; +} +var extendedFormattingField = import_state.StateField.define({ + create(state) { + let delimField = /* @__PURE__ */ new Map(); + let tree = (0, import_language4.syntaxTree)(state); + delimRegExps.forEach((value, type) => { + delimField.set(type, getDelimiterFromEditor(state.doc, tree, type, value.openingDelim, value.closingDelim)); + }); + return { delimField, treeLength: tree.length }; + }, + update(value, transaction) { + let tree = (0, import_language4.syntaxTree)(transaction.state); + transaction.effects.forEach((value2) => { + var _a, _b; + tree = (_b = (_a = value2.value) == null ? void 0 : _a.tree) != null ? _b : tree; + }); + if (transaction.docChanged || tree.length != value.treeLength) { + let from = Math.min(value.treeLength, tree.length); + value.treeLength = tree.length; + transaction.changes.iterChangedRanges((fromA) => { + from = Math.min(from, fromA); + }, false); + value.delimField.forEach((posCollection, type) => { + let { openingDelim, closingDelim, length } = delimRegExps.get(type); + getDelimiterFromEditor(transaction.state.doc, tree, type, openingDelim, closingDelim, { from, delimLength: length, posCollection }); + }); + } + return value; + } +}); + +// src/editorPlugins/stateFields/customHighlightField.ts +var import_state2 = require("@codemirror/state"); +var import_language5 = require("@codemirror/language"); +function getHighlight(doc, tree, previous) { + let text = doc.toString(); + let hlChecker = /highlight/; + let hlDelim = /(?:(? { + if (from >= hlProp[1]) { + if (from < hlProp[3] || from == hlProp[3]) { + lastIndex = hlProp[0]; + collection.splice(index); + } else { + collection.splice(index + 1); + } + return true; + } else if (index == 0) { + collection.splice(0); + return true; + } + }); + hlDelim.lastIndex = lastIndex; + } + for (let hlMatch = hlDelim.exec(text), node; hlMatch && (node = tree.resolveInner(hlMatch.index, 1)); hlMatch = hlDelim.exec(text)) { + let openingFrom = node.from; + if (openingFrom > tree.length) { + break; + } + if (!hlChecker.test(node.name)) { + continue; + } + let to; + while (true) { + to = node.to; + node = tree.resolveInner(node.to, 1); + if (!hlChecker.test(node.name)) { + break; + } + } + let lastNode = tree.resolveInner(to, -1); + let innerHlEnd = /formatting-highlight/.test(lastNode.name) ? lastNode.from : to; + hlCollection.push([openingFrom, hlDelim.lastIndex, innerHlEnd, to, hlMatch[1]]); + hlDelim.lastIndex = to; + } + return hlCollection; +} +var customHighlightField = import_state2.StateField.define({ + create(state) { + let tree = (0, import_language5.syntaxTree)(state); + return { + hlCollection: getHighlight(state.doc, tree), + treeLength: tree.length + }; + }, + update(value, transaction) { + let tree = (0, import_language5.syntaxTree)(transaction.state); + transaction.effects.forEach((value2) => { + var _a, _b; + tree = (_b = (_a = value2.value) == null ? void 0 : _a.tree) != null ? _b : tree; + }); + if (transaction.docChanged || tree.length != value.treeLength) { + let from = Math.min(value.treeLength, tree.length); + value.treeLength = tree.length; + transaction.changes.iterChangedRanges((fromA) => { + from = Math.min(from, fromA); + }, false); + getHighlight(transaction.state.doc, tree, { from, hlCollection: value.hlCollection }); + } + return value; + } +}); + +// src/editorPlugins/widgets/ColorButton.ts +var import_view = require("@codemirror/view"); +var import_obsidian = require("obsidian"); +var ColorButton = class extends import_view.WidgetType { + constructor(color = "default", markerFrom, markerTo, outerFrom, outerTo, innerFrom, innerTo) { + super(); + __publicField(this, "color"); + __publicField(this, "markerFrom"); + __publicField(this, "markerTo"); + __publicField(this, "menu"); + __publicField(this, "outerFrom"); + __publicField(this, "outerTo"); + __publicField(this, "innerFrom"); + __publicField(this, "innerTo"); + __publicField(this, "colors", ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink", "accent", "default"]); + this.color = color; + this.markerFrom = markerFrom; + this.markerTo = markerTo; + this.outerFrom = outerFrom; + this.outerTo = outerTo; + this.innerFrom = innerFrom; + this.innerTo = innerTo; + } + eq(other) { + return other.color == this.color; + } + toDOM(view) { + let btn = document.createElement("span"); + btn.setAttribute("aria-hidden", "true"); + btn.className = "cmx-color-btn"; + btn.onclick = (evt) => { + view.dispatch({ + selection: { + anchor: this.markerFrom, + head: this.markerTo + } + }); + }; + btn.onmouseover = (evt) => { + if (this.menu != null) { + return; + } + this.menu = new import_obsidian.Menu(); + this.menu.dom.addClass("es-highlight-colors-modal"); + this.colors.forEach((color) => { + this.menu.addItem((item) => { + item.setTitle(color).onClick((evt2) => { + view.dispatch({ + changes: { + from: this.markerFrom, + to: this.markerTo, + insert: color != "default" ? `{${color}}` : "" + } + }); + }).setIcon("palette"); + item.dom.addClass(`es-item-${color}`); + }); + }); + this.menu.addItem((item) => { + item.setTitle("Remove").setIcon("ban").onClick((evt2) => { + view.dispatch({ + changes: [{ + from: this.outerFrom, + to: this.markerTo, + insert: "" + }, { + from: this.innerTo, + to: this.outerTo, + insert: "" + }] + }); + }); + }); + const rect = btn.getBoundingClientRect(); + this.menu.showAtPosition({ x: rect.left, y: rect.bottom }); + }; + return btn; + } + ignoreEvent() { + return false; + } +}; + +// src/editorPlugins/widgets/HiddenWidget.ts +var import_view2 = require("@codemirror/view"); +var HiddenWidget = class extends import_view2.WidgetType { + constructor(formattingMark) { + super(); + __publicField(this, "mark"); + this.mark = formattingMark; + } + eq(other) { + return other.mark == this.mark; + } + toDOM(view) { + return document.createElement("span"); + } +}; + +// src/editorPlugins/decorators/alignerDecorators.ts +var import_view3 = require("@codemirror/view"); +var alignerDecorators = [ + import_view3.Decoration.line({ + class: "cmx-align-left", + query: /(?<=^|^#{1,6} +)!left!/gm, + style: "text-align: left;", + marker: import_view3.Decoration.mark({ class: "cmx-align-left-marker" }) + }), + import_view3.Decoration.line({ + class: "cmx-align-right", + query: /(?<=^|^#{1,6} +)!right!/gm, + style: "text-align: right;", + marker: import_view3.Decoration.mark({ class: "cmx-align-right-marker" }) + }), + import_view3.Decoration.line({ + class: "cmx-align-center", + query: /(?<=^|^#{1,6} +)!center!/gm, + style: "text-align: center;", + marker: import_view3.Decoration.mark({ class: "cmx-align-center-marker" }) + }), + import_view3.Decoration.line({ + class: "cmx-align-justify", + query: /(?<=^|^#{1,6} +)!justify!/gm, + style: "text-align: justify;", + marker: import_view3.Decoration.mark({ class: "cmx-align-justify-marker" }) + }) +]; + +// src/editorPlugins/decorators/highlightDecorator.ts +var import_view4 = require("@codemirror/view"); +var highlightDecorator = { + class: "cmx-highlight cmx-highlight", + markerDeco: import_view4.Decoration.mark({ class: "cmx-highlight-marker" }) +}; + +// src/editorPlugins/decorators/formattingDecorators.ts +var import_view5 = require("@codemirror/view"); +var formattingDecorators = /* @__PURE__ */ new Map([ + ["u" /* U */, import_view5.Decoration.mark({ class: "cmx-underline", delimDeco: import_view5.Decoration.mark({ class: "cmx-underline cmx-formatting-underline" }) })], + ["sup" /* Sup */, import_view5.Decoration.mark({ class: "cmx-superscript", delimDeco: import_view5.Decoration.mark({ class: "cmx-superscript cmx-formatting-superscript" }) })], + ["sub" /* Sub */, import_view5.Decoration.mark({ class: "cmx-subscript", delimDeco: import_view5.Decoration.mark({ class: "cmx-subscript cmx-formatting-subscript" }) })], + ["spoiler" /* Spoiler */, import_view5.Decoration.mark({ class: "cmx-spoiler", delimDeco: import_view5.Decoration.mark({ class: "cmx-spoiler cmx-formatting-spoiler" }) })] +]); + +// src/editorPlugins/decorators/hiddenSpoiler.ts +var import_view6 = require("@codemirror/view"); +var hiddenSpoiler = import_view6.Decoration.mark({ + class: "cmx-spoiler cmx-spoiler-hidden" +}); + +// src/editorPlugins/extendedFormattingPlugin.ts +var ExtendedFormatting = class { + constructor(view) { + __publicField(this, "decorations"); + __publicField(this, "isLivePreview"); + this.decorations = this.buildDecorations(view); + this.isLivePreview = view.state.field(import_obsidian2.editorLivePreviewField); + } + update(update) { + if (update.state.field(import_obsidian2.editorLivePreviewField) == this.isLivePreview) { + this.decorations = this.buildDecorations(update.view); + } + this.isLivePreview = update.state.field(import_obsidian2.editorLivePreviewField); + if (update.docChanged || update.selectionSet) { + this.decorations = this.buildDecorations(update.view); + } + } + buildDecorations(view) { + let newDecorations = []; + view.state.field(extendedFormattingField).delimField.forEach((posCollection, type) => { + if (posCollection.length === 0) { + return; + } + ; + let decorator = formattingDecorators.get(type); + let delimDecorator = decorator == null ? void 0 : decorator.spec.delimDeco; + posCollection.forEach((pos) => { + let hasClosingDelim = pos[2] != pos[3]; + if (type != "spoiler") { + newDecorations.push(decorator.range(pos[1], pos[2])); + } + newDecorations.push(delimDecorator.range(pos[0], pos[1])); + if (hasClosingDelim) { + newDecorations.push(delimDecorator.range(pos[2], pos[3])); + } + if ((!view.hasFocus || !checkSelectionOverlap(view.state.selection, pos[0], pos[3])) && this.isLivePreview) { + type == "spoiler" && newDecorations.push(hiddenSpoiler.range(pos[1], pos[2])); + this.hideDelim(newDecorations, delimDecorator, pos, hasClosingDelim); + } else if (type == "spoiler") { + newDecorations.push(decorator.range(pos[1], pos[2])); + } + }); + }); + return import_view7.Decoration.set(newDecorations, true); + } + hideDelim(decorations, decorator, pos, hasClosingDelim) { + let hiddenDelim = import_view7.Decoration.replace({ + widget: new HiddenWidget(decorator) + }); + decorations.push(hiddenDelim.range(pos[0], pos[1])); + if (hasClosingDelim) { + decorations.push(hiddenDelim.range(pos[2], pos[3])); + } + } +}; +var extendedFormattingPlugin = import_view7.ViewPlugin.fromClass(ExtendedFormatting, { + decorations: (value) => value.decorations +}); + +// src/editorPlugins/alignerPlugin.ts +var import_view8 = require("@codemirror/view"); +var import_search = require("@codemirror/search"); +var import_obsidian3 = require("obsidian"); +var Aligner = class { + constructor(view) { + __publicField(this, "decorations"); + __publicField(this, "isLivePreview"); + this.decorations = this.buildDecorations(view); + this.isLivePreview = view.state.field(import_obsidian3.editorLivePreviewField); + } + update(update) { + if (update.state.field(import_obsidian3.editorLivePreviewField) == this.isLivePreview) { + this.decorations = this.buildDecorations(update.view); + } + this.isLivePreview = update.state.field(import_obsidian3.editorLivePreviewField); + if (update.docChanged || update.selectionSet) { + this.decorations = this.buildDecorations(update.view); + } + } + buildDecorations(view) { + let newDecorations = []; + alignerDecorators.forEach((deco) => { + let searchCursor = new import_search.RegExpCursor(view.state.doc, deco.spec.query).next(); + if (searchCursor.done) { + return newDecorations; + } + ; + let marker = deco.spec.marker; + while (!searchCursor.done) { + let [from, to] = [searchCursor.value.from, searchCursor.value.to]; + if (!isCodeblock(view, from, to)) { + let linePosFrom = view.state.doc.lineAt(from).from; + newDecorations.push(deco.range(linePosFrom, linePosFrom)); + newDecorations.push(marker.range(from, to)); + if (!checkSelectionOverlap(view.state.selection, from, to) && this.isLivePreview) { + this.hideMarker(newDecorations, marker, from, to); + } + } + searchCursor.next(); + } + }); + return import_view8.Decoration.set(newDecorations, true); + } + hideMarker(decorations, marker, from, to) { + let hiddenMarker = import_view8.Decoration.replace({ + widget: new HiddenWidget(marker) + }); + decorations.push(hiddenMarker.range(from, to)); + } +}; +var alignerPlugin = import_view8.ViewPlugin.fromClass(Aligner, { + decorations: (value) => value.decorations +}); + +// src/editorPlugins/customHighlightPlugin.ts +var import_view9 = require("@codemirror/view"); +var import_obsidian4 = require("obsidian"); +var CustomHighlight = class { + constructor(view) { + __publicField(this, "decorations"); + __publicField(this, "isLivePreview"); + this.decorations = this.buildDecorations(view); + this.isLivePreview = view.state.field(import_obsidian4.editorLivePreviewField); + } + update(update) { + if (update.state.field(import_obsidian4.editorLivePreviewField) == this.isLivePreview) { + this.decorations = this.buildDecorations(update.view); + } + this.isLivePreview = update.state.field(import_obsidian4.editorLivePreviewField); + if (update.docChanged || update.selectionSet) { + this.decorations = this.buildDecorations(update.view); + } + } + buildDecorations(view) { + let newDecorations = []; + let marker = highlightDecorator.markerDeco; + view.state.field(customHighlightField).hlCollection.forEach((hl) => { + let [outerFrom, innerFrom, innerTo, outerTo, color] = [...hl]; + let markerLength = color ? color.length + 2 : 0; + newDecorations.push( + import_view9.Decoration.mark({ class: `${highlightDecorator.class}-${color != null ? color : "default"}` }).range(outerFrom, outerTo), + import_view9.Decoration.mark({ class: `${highlightDecorator.class}-first-letter` }).range(innerFrom + markerLength, innerFrom + markerLength + 1), + import_view9.Decoration.mark({ class: `${highlightDecorator.class}-last-letter` }).range(innerTo - 1, innerTo) + ); + if (checkSelectionOverlap(view.state.selection, outerFrom, outerTo) || !this.isLivePreview) { + newDecorations.push(import_view9.Decoration.widget({ widget: new ColorButton( + color, + innerFrom, + innerFrom + markerLength, + outerFrom, + outerTo, + innerFrom, + innerTo + ), side: 1 }).range(innerFrom)); + } + if (color && !(checkSelectionOverlap(view.state.selection, innerFrom, innerFrom + markerLength) || !this.isLivePreview)) { + this.hideMarker(newDecorations, marker, innerFrom, innerFrom + markerLength); + } + }); + return import_view9.Decoration.set(newDecorations, true); + } + hideMarker(decorations, marker, from, to) { + let hiddenMarker = import_view9.Decoration.replace({ + widget: new HiddenWidget(marker) + }); + decorations.push( + hiddenMarker.range(from, to) + ); + } +}; +var customHighlightPlugin = import_view9.ViewPlugin.fromClass(CustomHighlight, { + decorations: (value) => value.decorations +}); + +// src/postProcessor/AlignerPostProcessor.ts +var AlignerPostProcessor = class { + constructor() { + __publicField(this, "targetedElements", "p, h1, h2, h3, h4, h5, h6, td, th, .callout-title-inner"); + __publicField(this, "format", (el) => { + var _a, _b; + let alignMark = /^!((?:left)|(?:right)|(?:center)|(?:justify))!/; + let alignMarkExecArr; + if (((_a = el.parentElement) == null ? void 0 : _a.tagName) == "BLOCKQUOTE") { + return; + } + if ((_b = el.parentElement) == null ? void 0 : _b.hasClass("callout-content")) { + alignMark = /^ *!((?:left)|(?:right)|(?:center)|(?:justify))!/; + alignMarkExecArr = alignMark.exec(el.innerHTML); + } else if (el.toString() == "[object HTMLHeadingElement]" && el.childNodes[1].nodeType == 3) { + alignMarkExecArr = alignMark.exec(el.childNodes[1].textContent); + } else { + alignMarkExecArr = alignMark.exec(el.innerHTML); + } + if (alignMarkExecArr) { + el.innerHTML = el.innerHTML.replace(alignMarkExecArr[0], ""); + if (el.hasClass("callout-title-inner")) { + el.parentElement.style.justifyContent = alignMarkExecArr[1] != "justify" ? alignMarkExecArr[1] : "space-between"; + } else { + el.style.textAlign = alignMarkExecArr[1]; + } + } + }); + __publicField(this, "postProcess", (container) => { + if (container.classList.contains("table-cell-wrapper")) { + this.format(container); + } else { + container.querySelectorAll(this.targetedElements).forEach((el) => { + this.format(el); + }); + } + }); + } +}; + +// src/postProcessor/CustomHighlightPostProcessor.ts +var CustomHighlightPostProcessor = class { + constructor() { + __publicField(this, "format", (el) => { + let markElements = el.querySelectorAll("mark"); + let colorMark = /^\{([\w\d\-_]+)\}/; + markElements.forEach((mark) => { + let colorMarkExecArr = colorMark.exec(mark.innerText); + if (colorMarkExecArr) { + mark.innerHTML = mark.innerHTML.replace(colorMarkExecArr[0], ""); + mark.classList.add(`cmx-highlight-${colorMarkExecArr[1]}`); + } + }); + }); + __publicField(this, "postProcess", (container, t) => { + this.format(container); + }); + } +}; + +// src/utils/postProcess/splitCells.ts +function splitCells(text) { + let rows = text.split("\n"); + let cells = []; + rows.forEach((row, index) => { + if (index === 1) { + return; + } + row = row.trim(); + row.startsWith("|") && (row = row.substring(1)); + row.endsWith("|") && (row = row.substring(0, row.length - 1)); + row.split(/(?:(? { + cells.push(cell); + }); + }); + return cells; +} + +// src/utils/postProcess/getEscapedDelims.ts +function getEscapedDelims(text, openingDelim, closingDelim) { + let restrictedRanges = getRestrictedRanges(text); + let indexes = []; + openingDelim.lastIndex = 0; + closingDelim.lastIndex = 0; + for (let openingMatch = openingDelim.exec(text), i = 0; openingMatch; openingMatch = openingDelim.exec(text)) { + let [all, escaper, target, slippedBackslash, next] = openingMatch.indices; + if (restrictedRanges.some((range) => range[0] <= all[0] && range[1] >= all[1])) { + continue; + } + closingDelim.lastIndex = all[1]; + if (escaper || slippedBackslash || next) { + indexes.push(i); + openingDelim.lastIndex = target[0] + 1; + i++; + continue; + } + i++; + for (let closingMatch = closingDelim.exec(text); closingMatch || (openingDelim.lastIndex = text.length, false); closingMatch = closingDelim.exec(text)) { + let [all2, escaper2, nonEscaper, target2, slippedBackslash2] = [...closingMatch.indices]; + if (restrictedRanges.some((range) => range[0] <= all2[0] && range[1] >= all2[1])) { + continue; + } + if (escaper2 || slippedBackslash2) { + indexes.push(i); + closingDelim.lastIndex = target2[0] + 1; + i++; + continue; + } + i++; + openingDelim.lastIndex = all2[1]; + break; + } + } + return indexes; +} + +// src/utils/postProcess/getDelimiterFromPreview.ts +function getDelimiterFromPreview(text, rawText, delimQuery) { + var _a, _b, _c, _d, _e, _f, _g; + let newLineChar = /(
\n?)\1+/g; + let htmlTagRegExp = /<[\w\d\-]+(?: *[\w\d\-]+(?:=(?:"[^"]*")|(?:'[^']*'))?)*>/g; + let currentLineEndOffset = (_b = (_a = newLineChar.exec(text)) == null ? void 0 : _a.index) != null ? _b : text.length; + let currentTagExecArr = htmlTagRegExp.exec(text); + let currentTagRange = [ + (_c = currentTagExecArr == null ? void 0 : currentTagExecArr.index) != null ? _c : text.length, + htmlTagRegExp.lastIndex || text.length + ]; + let { openingDelim, closingDelim, raw } = delimQuery; + let escapedIndexes = getEscapedDelims(rawText, raw.openingDelim, raw.closingDelim); + let delimPosCollection = []; + openingDelim.lastIndex = 0; + closingDelim.lastIndex = 0; + for (let openingMatch = openingDelim.exec(text), lastIndex = openingDelim.lastIndex, i = 0; openingMatch; openingMatch = openingDelim.exec(text), lastIndex = openingDelim.lastIndex) { + let delimPos = [0, 0, 0, 0]; + if (lastIndex > currentTagRange[1]) { + htmlTagRegExp.lastIndex = lastIndex; + currentTagRange = [ + ((_d = htmlTagRegExp.exec(text)) == null ? void 0 : _d.index) || text.length, + htmlTagRegExp.lastIndex || text.length + ]; + } + if (lastIndex > currentTagRange[0] && lastIndex < currentTagRange[1]) { + continue; + } + if (escapedIndexes[0] === i) { + openingDelim.lastIndex = openingMatch.index + 1; + escapedIndexes.shift(); + i++; + continue; + } + if (lastIndex > currentLineEndOffset) { + newLineChar.lastIndex = lastIndex; + currentLineEndOffset = ((_e = newLineChar.exec(text)) == null ? void 0 : _e.index) || text.length; + } + closingDelim.lastIndex = lastIndex; + [delimPos[0], delimPos[1]] = openingMatch.indices[0]; + i++; + for (let closingMatch = closingDelim.exec(text); ; closingMatch = closingDelim.exec(text)) { + lastIndex = closingDelim.lastIndex; + if (!closingMatch || lastIndex > currentLineEndOffset) { + openingDelim.lastIndex = delimPos[2] = delimPos[3] = currentLineEndOffset; + currentLineEndOffset = ((_f = newLineChar.exec(text)) == null ? void 0 : _f.index) || text.length; + } else if (lastIndex > currentTagRange[0] && lastIndex < currentTagRange[1]) { + continue; + } else if (escapedIndexes[0] === i) { + closingDelim.lastIndex = closingMatch.index + 1; + escapedIndexes.shift(); + i++; + continue; + } else { + if (lastIndex > currentTagRange[1]) { + htmlTagRegExp.lastIndex = lastIndex; + currentTagRange = [ + ((_g = htmlTagRegExp.exec(text)) == null ? void 0 : _g.index) || text.length, + htmlTagRegExp.lastIndex || text.length + ]; + } + [delimPos[2], delimPos[3]] = closingMatch.indices[0]; + openingDelim.lastIndex = lastIndex; + i++; + } + break; + } + delimPosCollection.push(delimPos); + } + return delimPosCollection; +} + +// src/utils/postProcess/getRestrictedRange.ts +function getRestrictedRanges(text) { + let query = new RegExp("(\\\\)|`(?:[^`])+`|\\[\\[\\[?(?!\\[)(?:.|\\s)+?(? { + excludedContents.push(el.innerHTML); + el.innerHTML = ""; + }); + let sanitizedText = contentEl.innerHTML; + let ranges = getDelimiterFromPreview(sanitizedText, rawText, delimQuery); + if (ranges.length) { + let delimLength = delimQuery == null ? void 0 : delimQuery.length; + let lengthA = tagName.length + 2 - delimLength; + let lengthB = tagName.length + 3; + let i = 0; + ranges.forEach((value) => { + sanitizedText = replaceStringByPos(sanitizedText, `<${tagName}>`, value[0] + i, value[1] + i); + i += lengthA; + sanitizedText = replaceStringByPos(sanitizedText, ``, value[2] + i, value[3] + i); + i = value[2] == value[3] ? i + lengthB : i + lengthB - delimLength; + }); + } + ; + contentEl.innerHTML = sanitizedText; + excludedContents.forEach((content, index) => { + contentEl.querySelectorAll(excludedSelector)[index].innerHTML = content; + }); +} + +// src/utils/postProcess/getBlockquoteSections.ts +function getBlockquoteSections(text, isCallout) { + let blockquoteDelim = /^ *>/; + let sections = []; + text.split("\n").forEach((text2) => { + let level = 0; + while (blockquoteDelim.test(text2) && ++level) { + text2 = text2.replace(blockquoteDelim, ""); + } + text2 = text2.trimStart(); + sections.push({ + text: text2, + level + }); + }); + loop1: for (let i = 0, mergePrevious = false; i < sections.length; ) { + let currentLine = sections[i]; + if (isCallout && i === 0) { + i++; + continue; + } + if (/^```/.test(currentLine.text)) { + let k = 1; + while (sections[i + k]) { + let { level, text: text2 } = sections[i + k++]; + if (text2 === "" && level < currentLine.level || /^``` *$/.test(text2) && level == currentLine.level) { + break; + } + } + sections.splice(i, k), mergePrevious && (mergePrevious = false); + continue loop1; + } else if (/^\$\$/.test(currentLine.text)) { + let k = 1; + while (sections[i + k]) { + let { level, text: text2 } = sections[i + k++]; + if (text2 === "" && level < currentLine.level && k-- || /^\$\$ *$/.test(text2) && level == currentLine.level) { + break; + } + } + if (k !== 1) { + sections.splice(i, k), mergePrevious && (mergePrevious = false); + continue loop1; + } + } else if (currentLine.text === "") { + sections.splice(i, 1), mergePrevious && (mergePrevious = false); + continue loop1; + } + mergePrevious && sections[i - 1].level >= currentLine.level && (sections[i - 1].text += ` +${sections.splice(i, 1)[0].text}`, i--); + i++, mergePrevious || (mergePrevious = true); + } + return sections; +} + +// src/postProcessor/ExtendedFormattingPostProcessor.ts +var ExtendedFormattingPostProcessor = class { + constructor(workspace) { + __publicField(this, "view"); + __publicField(this, "workspace"); + __publicField(this, "targetedElements", "p, li, h1, h2, h3, h4, h5, h6, .callout-title-inner"); + __publicField(this, "excludedSelector", "code, a.internal-link"); + __publicField(this, "postProcess", (container, ctx) => { + var _a, _b, _c, _d, _e, _f, _g; + let sectionInfo = ctx.getSectionInfo(container); + if (sectionInfo) { + let rawTextData = getTextAtLine(sectionInfo.text, sectionInfo.lineStart, sectionInfo.lineEnd); + let firstChild = container.firstElementChild; + if ((firstChild == null ? void 0 : firstChild.tagName) == "TABLE") { + splitCells(rawTextData).forEach((rawText, i) => { + this.format(container.querySelectorAll("td, th")[i], rawText, true); + }); + } else if ((firstChild == null ? void 0 : firstChild.tagName) == "BLOCKQUOTE") { + getBlockquoteSections(rawTextData, false).forEach((section, i) => { + this.format(container.querySelectorAll(this.targetedElements)[i], section.text); + }); + } else if (firstChild == null ? void 0 : firstChild.classList.contains("callout")) { + getBlockquoteSections(rawTextData, true).forEach((section, i) => { + this.format(container.querySelectorAll(this.targetedElements)[i], section.text); + }); + } else { + container.querySelectorAll(this.targetedElements).forEach((contentEl) => { + this.format(contentEl, rawTextData); + }); + } + container.querySelectorAll("spoiler").forEach((el) => { + el.addEventListener("click", (event) => { + let spoiler = event.currentTarget; + spoiler.hasClass("cmx-revealed") ? spoiler.removeClass("cmx-revealed") : spoiler.addClass("cmx-revealed"); + }); + }); + } else { + let cmEditor = (_e = (_b = (_a = this.view) == null ? void 0 : _a.editor) == null ? void 0 : _b.cm) != null ? _e : (_d = (_c = this.workspace._["quick-preview"].find((evtRef) => { + var _a2, _b2; + return ((_a2 = evtRef.ctx) == null ? void 0 : _a2.hasOwnProperty("editMode")) && ((_b2 = evtRef.ctx) == null ? void 0 : _b2.path) == ctx.sourcePath; + })) == null ? void 0 : _c.ctx) == null ? void 0 : _d.editMode.cm; + if (container.classList.contains("table-cell-wrapper")) { + let tableBlock = cmEditor.docView.children.find((el) => { + var _a2; + return ((_a2 = el.widget) == null ? void 0 : _a2.containerEl) == ctx.containerEl; + }); + let tableCells = (_f = tableBlock == null ? void 0 : tableBlock.widget) == null ? void 0 : _f.cellChildMap.keys(); + for (let cell of tableCells) { + if (cell.contentEl == ctx.el) { + this.format(container, cell.text, true); + break; + } + } + } else if (ctx.containerEl.classList.contains("cm-callout")) { + let calloutBlock = cmEditor.docView.children.find((el) => { + var _a2; + return ((_a2 = el.widget) == null ? void 0 : _a2.containerEl) == ctx.containerEl; + }); + let calloutText = (_g = calloutBlock == null ? void 0 : calloutBlock.widget) == null ? void 0 : _g.text; + getBlockquoteSections(calloutText, true).forEach((section, i) => { + this.format(container.querySelectorAll(this.targetedElements)[i], section.text); + }); + } + } + }); + this.view = workspace.activeEditor; + this.workspace = workspace; + } + format(contentEl, rawText, isTableCell) { + postProcessorDelimRegExps.forEach((delimQuery, tagName) => { + iterDelimReplacement(contentEl, rawText, delimQuery, tagName, this.excludedSelector, isTableCell); + }); + } +}; + +// main.ts +var ExtendedMarkdownSyntax = class extends import_obsidian5.Plugin { + async onload() { + await this.loadData(); + this.registerEditorExtension([ + extendedFormattingField, + customHighlightField + ]); + this.registerEditorExtension([ + extendedFormattingPlugin, + customHighlightPlugin, + alignerPlugin, + (0, import_view10.drawSelection)() + ]); + this.registerMarkdownPostProcessor(new ExtendedFormattingPostProcessor(this.app.workspace).postProcess); + this.registerMarkdownPostProcessor(new CustomHighlightPostProcessor().postProcess); + this.registerMarkdownPostProcessor(new AlignerPostProcessor().postProcess); + console.log("Load Extended Markdown Syntax"); + } + onunload() { + console.log("Unload Extended Markdown Syntax"); + } +}; diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..2e32085 --- /dev/null +++ b/main.ts @@ -0,0 +1,37 @@ +import { Plugin } from "obsidian"; +import { drawSelection } from "@codemirror/view"; +import { extendedFormattingPlugin, customHighlightPlugin, alignerPlugin } from "./src/editorPlugins"; +import { customHighlightField, extendedFormattingField } from "./src/editorPlugins/stateFields"; +import { ExtendedFormattingPostProcessor, CustomHighlightPostProcessor, AlignerPostProcessor } from "./src/postProcessor"; + +export default class ExtendedMarkdownSyntax extends Plugin { + + async onload() { + await this.loadData(); + + // state fields + this.registerEditorExtension([ + extendedFormattingField, + customHighlightField + ]); + + // view plugins + this.registerEditorExtension([ + extendedFormattingPlugin, + customHighlightPlugin, + alignerPlugin, + drawSelection() + ]); + + // postprocessor + this.registerMarkdownPostProcessor(new ExtendedFormattingPostProcessor(this.app.workspace).postProcess); + this.registerMarkdownPostProcessor(new CustomHighlightPostProcessor().postProcess); + this.registerMarkdownPostProcessor(new AlignerPostProcessor().postProcess); + + console.log("Load Extended Markdown Syntax"); + } + + onunload(): void { + console.log("Unload Extended Markdown Syntax"); + } +} \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..f79298a --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "extended-markdown-syntax", + "name": "Extended Markdown Syntax", + "version": "0.1.0", + "minAppVersion": "0.15.0", + "description": "Extend your markdown syntax using delimiters instead of HTML tags, such as underlining, superscript, subscript, highlighting, aligning.", + "author": "Kotaindah55 (Sheva Ihza)", + "isDesktopOnly": true +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..21d7b20 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "extended-markdown-syntax", + "version": "0.1.0", + "description": "Extend your markdown syntax using delimiters instead of HTML tags, such as underlining, superscript, subscript, highlighting, spoiler, and aligning.", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "version": "node version-bump.mjs && git add manifest.json versions.json" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@types/node": "^16.11.6", + "@typescript-eslint/eslint-plugin": "5.29.0", + "@typescript-eslint/parser": "5.29.0", + "builtin-modules": "3.3.0", + "esbuild": "0.17.3", + "obsidian": "latest", + "obsidian-typings": "2.2.0", + "tslib": "2.4.0", + "typescript": "4.7.4" + } +} \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..69c9f1c --- /dev/null +++ b/readme.md @@ -0,0 +1,146 @@ +# Extended Markdown Syntax + +This plugin provides some alternatives for inline formatting using non-standard syntaxes instead of using html tags, such as underline, superscript, and much more. + +You can easily create text that is underlined, superscripted, or subscripted without any pain of writing html tags. And don't forget, this plugin supports both modes: editor mode and preview mode. + +![[ems1.webm]] + +## Features + +This plugin has four main features: + +1. Inline formatting (underline, superscript, subscript). +2. Paragraph aligning. +3. Custom highlight color. +4. Discord-flavoured spoiler. + +> [!note] +> As it should be, the syntax will not be parsed if it is inside inline-code, codeblock, inline-math, mathblock, and internal links. Instead, they will still be styled by those syntaxes if they are inside them. + +### Inline Formatting + +| Format | Delimiter | +| ----------- | --------------- | +| underline | `++your text++` | +| superscript | `^your text^` | +| superscript | `~your text~` | + +You can format text by wrapping it with delimiters. + +```markdown +++underline++ ^superscript^ ~subscript~ +``` + +This will result: +![[Screenshot from 2024-09-24 12-13-28.png]] + +You can also combine various formatting without any rendering issues in live-preview mode.: + +```markdown +Lorem ++ip*s*um++ dolor sit amet, consectetur adipiscing elit. Aenean sed enim ut dui vehicula **eleifend ++at ~non~++ magna**. Vestibulum viverra imperdiet magna ut pharetra. Proin eleifend orci felis, eget ultricies velit varius quis. Aliquam quis auctor lectus. Donec ~~cong^ue^~~ sed nibh sollicitudin dignissim. +``` + +![[ems2.webm]] + +Backslash can also be used to escape delimiters, so the text will not be formatted. + +```markdown +++will be formatted++ \++will be escaped++ +\+also will be escaped+\+. +``` + +![[ems3.png]] + +### Paragraph Aligning + +| Align | Marker | +| ------- | ----------- | +| left | `!left!` | +| right | `!right!` | +| center | `!center!` | +| justify | `!justify!` | + +You can easily set the alignment of a paragraph by writing the aligning-marker at the beginning of the paragraph. + +```markdown +!left!Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean sed enim ut dui vehicula eleifend at non magna. Vestibulum viverra imperdiet magna ut pharetra. Proin eleifend orci felis, eget ultricies velit varius quis. Aliquam quis auctor lectus. Donec congue sed nibh sollicitudin dignissim. + +!right!Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean sed enim ut dui vehicula eleifend at non magna. Vestibulum viverra imperdiet magna ut pharetra. Proin eleifend orci felis, eget ultricies velit varius quis. Aliquam quis auctor lectus. Donec congue sed nibh sollicitudin dignissim. + +!center!Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean sed enim ut dui vehicula eleifend at non magna. Vestibulum viverra imperdiet magna ut pharetra. Proin eleifend orci felis, eget ultricies velit varius quis. Aliquam quis auctor lectus. Donec congue sed nibh sollicitudin dignissim. + +!justify!Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean sed enim ut dui vehicula eleifend at non magna. Vestibulum viverra imperdiet magna ut pharetra. Proin eleifend orci felis, eget ultricies velit varius quis. Aliquam quis auctor lectus. Donec congue sed nibh sollicitudin dignissim. +``` + +This will shown as: +![[ems4.png]] + +Marker can also be applied to headings by placing it after hash marks and space: + +``` +# !{alignment}!your heading +``` + +![[ems5.webm]] + +Moreover, markers can also be applied to callouts, both titles and content, and table cells. + +``` +> [!note] !{alignment}!Your callout title +> !{alignment}!Your callout content + +To adjust the alignment of callout title, place alignment marker after callout type identifier (e.g. "[!note]") and space. +``` + +![[ems10.webm]] + +> [!note] +> For now, I don't include the alignment feature in blockquotes for several reasons. + +### Customize Your Highlight Color + +You can customize your highlight color simply by adding color tag after opening delimiter with the following format: + +```markdown +=={color tag}highlighted text== +``` + +![[ems6.webm]] + +For the moment, the supported colors are red, orange, yellow, green, cyan, blue, purple, pink (there is also your accent color). If you don't want to use custom colors you can let the highlighted text without color tag (like this: `==default highlighting==`). + +```markdown +=={red}Red== =={orange}Orange== =={yellow}Yellow== =={green}Green== =={cyan}Cyan== =={blue}Blue== =={purple}Purple== =={pink}Pink== =={accent}Your accent== ==Default== +``` + +This will be shown: +![[ems7.png]] + +Tired of retyping the color tag, you can change the highlight color by hovering over the color button and selecting any color you want (you can also remove the highlight). + +![[ems8.webm]] + +### Discord-Flavoured Spoiler + +Inspired by discord, you can create **spoilers** by wrapping text with `||`. In live-preview mode, spoilers can be revealed by hovering over them. Whereas in view mode, spoilers are revealed by clicking on them. Just like previous inline-formatting, the backslash can also be applied to escape delimiters. + +```markdown +||your spoiler|| +``` + +![[ems9.webm]] + +## Features to be Implemented in The Future + +- [ ] Enable/disable formatting in settings +- [ ] Customize formatting styles and highlighting colors +- [ ] Applying syntax quickly using shortcuts +- [ ] More syntaxes, if necessary + +## Known Issues + +- Cannot escape spoilers that are inside table cells +- The syntax in the heading is treated as normal text in the outline +- Syntax is not properly parsed in post-processed codeblocks such as Admonition + +Feel free to let me know if you find any bugs... \ No newline at end of file diff --git a/src/declarations/index.d.ts b/src/declarations/index.d.ts new file mode 100644 index 0000000..a2e37a8 --- /dev/null +++ b/src/declarations/index.d.ts @@ -0,0 +1,60 @@ +import { type WidgetType } from "@codemirror/view"; +import { type TableCell } from "obsidian"; + +declare module "obsidian" { + + interface Workspace { + _: EventListener; + } + + interface TableCell { + row: number; + col: number; + text: string; + contentEl: HTMLElement; + } + + interface EventListener { + ["quick-preview"]: EventRef[]; + } + + interface EventRef { + ctx?: T; + } + + interface MarkdownView { + path: string; + } + + interface MarkdownPostProcessorContext { + containerEl: HTMLElement; + el: HTMLElement; + } + +} + +declare module "@codemirror/view" { + + interface EditorView { + docView: DocView; + } + + interface DocView { + children: DocViewBlock[]; + } + + interface DocViewBlock { + widget?: T; + } + + class BlockWidget extends WidgetType { + toDOM(view: EditorView): HTMLElement; + containerEl: HTMLElement; + text: string; + } + + class TableWidget extends BlockWidget { + cellChildMap: Map; + } + +} \ No newline at end of file diff --git a/src/editorPlugins/alignerPlugin.ts b/src/editorPlugins/alignerPlugin.ts new file mode 100644 index 0000000..426298e --- /dev/null +++ b/src/editorPlugins/alignerPlugin.ts @@ -0,0 +1,71 @@ +import { Decoration, ViewPlugin, type PluginValue, type DecorationSet, type EditorView, type ViewUpdate } from "@codemirror/view"; +import { type Range } from "@codemirror/state"; +import { RegExpCursor } from "@codemirror/search"; +import { editorLivePreviewField } from "obsidian"; +import { checkSelectionOverlap } from "../utils/codemirror"; +import { isCodeblock } from "../utils/codemirror"; +import { HiddenWidget } from "./widgets"; +import { alignerDecorators } from "./decorators"; + +class Aligner implements PluginValue { + + decorations: DecorationSet; + isLivePreview: boolean; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + this.isLivePreview = view.state.field(editorLivePreviewField); + } + + update(update: ViewUpdate) { + + if (update.state.field(editorLivePreviewField) == this.isLivePreview) { + this.decorations = this.buildDecorations(update.view); + } + + this.isLivePreview = update.state.field(editorLivePreviewField); + + if (update.docChanged || update.selectionSet) { + this.decorations = this.buildDecorations(update.view); + + } + } + + buildDecorations(view: EditorView) { + + let newDecorations: Range[] = []; + + alignerDecorators.forEach((deco) => { + + let searchCursor = new RegExpCursor(view.state.doc, deco.spec.query).next(); + if (searchCursor.done) {return newDecorations}; + let marker = deco.spec.marker as Decoration; + + while (!searchCursor.done) { + let [from, to] = [searchCursor.value.from, searchCursor.value.to]; + if (!isCodeblock(view, from, to)) { + let linePosFrom = view.state.doc.lineAt(from).from; + newDecorations.push(deco.range(linePosFrom, linePosFrom)); + newDecorations.push(marker.range(from, to)); + if (!checkSelectionOverlap(view.state.selection, from, to) && this.isLivePreview) { + this.hideMarker(newDecorations, marker, from, to); + } + } + searchCursor.next(); + } + }); + + return Decoration.set(newDecorations, true); + } + + hideMarker(decorations: Range[], marker: Decoration, from: number, to: number) { + let hiddenMarker = Decoration.replace({ + widget: new HiddenWidget(marker) + }); + decorations.push(hiddenMarker.range(from, to)); + } +} + +export const alignerPlugin = ViewPlugin.fromClass(Aligner, { + decorations: (value) => value.decorations +}); \ No newline at end of file diff --git a/src/editorPlugins/customHighlightPlugin.ts b/src/editorPlugins/customHighlightPlugin.ts new file mode 100644 index 0000000..7d115c9 --- /dev/null +++ b/src/editorPlugins/customHighlightPlugin.ts @@ -0,0 +1,74 @@ +import { Decoration, ViewPlugin, type PluginValue, type DecorationSet, type EditorView, type ViewUpdate, drawSelection } from "@codemirror/view"; +import { type Range } from "@codemirror/state"; +import { editorLivePreviewField } from "obsidian"; +import { checkSelectionOverlap } from "../utils/codemirror"; +import { ColorButton, HiddenWidget } from "./widgets"; +import { highlightDecorator } from "./decorators"; +import { customHighlightField } from "./stateFields"; + +class CustomHighlight implements PluginValue { + + decorations: DecorationSet; + isLivePreview: boolean; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + this.isLivePreview = view.state.field(editorLivePreviewField); + } + + update(update: ViewUpdate) { + + if (update.state.field(editorLivePreviewField) == this.isLivePreview) { + this.decorations = this.buildDecorations(update.view); + } + + this.isLivePreview = update.state.field(editorLivePreviewField); + + if (update.docChanged || update.selectionSet) { + this.decorations = this.buildDecorations(update.view); + } + } + + buildDecorations(view: EditorView) { + + let newDecorations: Range[] = []; + let marker = highlightDecorator.markerDeco as Decoration; + + view.state.field(customHighlightField).hlCollection.forEach(hl => { + + let [outerFrom, innerFrom, innerTo, outerTo, color] = [...hl]; + let markerLength = color ? color.length + 2 : 0; + + newDecorations.push( + Decoration.mark({class: `${highlightDecorator.class}-${color ?? "default"}`}).range(outerFrom, outerTo), + Decoration.mark({class: `${highlightDecorator.class}-first-letter`}).range(innerFrom + markerLength, innerFrom + markerLength + 1), + Decoration.mark({class: `${highlightDecorator.class}-last-letter`}).range(innerTo - 1, innerTo) + ); + + if (checkSelectionOverlap(view.state.selection, outerFrom, outerTo) || !this.isLivePreview) { + newDecorations.push(Decoration.widget({widget: new ColorButton( + color, innerFrom, innerFrom + markerLength, outerFrom, outerTo, innerFrom, innerTo + ), side: 1}).range(innerFrom)); + } + + if (color && !(checkSelectionOverlap(view.state.selection, innerFrom, innerFrom + markerLength) || !this.isLivePreview)) { + this.hideMarker(newDecorations, marker, innerFrom, innerFrom + markerLength); + } + }); + + return Decoration.set(newDecorations, true); + } + + hideMarker(decorations: Range[], marker: Decoration, from: number, to: number) { + let hiddenMarker = Decoration.replace({ + widget: new HiddenWidget(marker) + }); + decorations.push( + hiddenMarker.range(from, to) + ); + } +} + +export const customHighlightPlugin = ViewPlugin.fromClass(CustomHighlight, { + decorations: (value) => value.decorations +}); \ No newline at end of file diff --git a/src/editorPlugins/decorators/alignerDecorators.ts b/src/editorPlugins/decorators/alignerDecorators.ts new file mode 100644 index 0000000..4012f5a --- /dev/null +++ b/src/editorPlugins/decorators/alignerDecorators.ts @@ -0,0 +1,28 @@ +import { Decoration } from "@codemirror/view" + +export const alignerDecorators = [ + Decoration.line({ + class: "cmx-align-left", + query: /(?<=^|^#{1,6} +)!left!/gm, + style: "text-align: left;", + marker: Decoration.mark({class: "cmx-align-left-marker"}) + }), + Decoration.line({ + class: "cmx-align-right", + query: /(?<=^|^#{1,6} +)!right!/gm, + style: "text-align: right;", + marker: Decoration.mark({class: "cmx-align-right-marker"}) + }), + Decoration.line({ + class: "cmx-align-center", + query: /(?<=^|^#{1,6} +)!center!/gm, + style: "text-align: center;", + marker: Decoration.mark({class: "cmx-align-center-marker"}) + }), + Decoration.line({ + class: "cmx-align-justify", + query: /(?<=^|^#{1,6} +)!justify!/gm, + style: "text-align: justify;", + marker: Decoration.mark({class: "cmx-align-justify-marker"}) + }), +] \ No newline at end of file diff --git a/src/editorPlugins/decorators/formattingDecorators.ts b/src/editorPlugins/decorators/formattingDecorators.ts new file mode 100644 index 0000000..57700fe --- /dev/null +++ b/src/editorPlugins/decorators/formattingDecorators.ts @@ -0,0 +1,9 @@ +import { Decoration } from "@codemirror/view"; +import { DelimType } from "../../enums"; + +export const formattingDecorators = new Map([ + [DelimType.U, Decoration.mark({class: "cmx-underline", delimDeco: Decoration.mark({class: "cmx-underline cmx-formatting-underline"})})], + [DelimType.Sup, Decoration.mark({class: "cmx-superscript", delimDeco: Decoration.mark({class: "cmx-superscript cmx-formatting-superscript"})})], + [DelimType.Sub, Decoration.mark({class: "cmx-subscript", delimDeco: Decoration.mark({class: "cmx-subscript cmx-formatting-subscript"})})], + [DelimType.Spoiler, Decoration.mark({class: "cmx-spoiler", delimDeco: Decoration.mark({class: "cmx-spoiler cmx-formatting-spoiler"})})] +]); \ No newline at end of file diff --git a/src/editorPlugins/decorators/hiddenSpoiler.ts b/src/editorPlugins/decorators/hiddenSpoiler.ts new file mode 100644 index 0000000..8df89f9 --- /dev/null +++ b/src/editorPlugins/decorators/hiddenSpoiler.ts @@ -0,0 +1,5 @@ +import { Decoration } from "@codemirror/view"; + +export const hiddenSpoiler = Decoration.mark({ + class: "cmx-spoiler cmx-spoiler-hidden" +}); \ No newline at end of file diff --git a/src/editorPlugins/decorators/highlightDecorator.ts b/src/editorPlugins/decorators/highlightDecorator.ts new file mode 100644 index 0000000..fc3793a --- /dev/null +++ b/src/editorPlugins/decorators/highlightDecorator.ts @@ -0,0 +1,6 @@ +import { Decoration } from "@codemirror/view"; + +export const highlightDecorator = { + class: "cmx-highlight cmx-highlight", + markerDeco: Decoration.mark({class: "cmx-highlight-marker"}) +} \ No newline at end of file diff --git a/src/editorPlugins/decorators/index.ts b/src/editorPlugins/decorators/index.ts new file mode 100644 index 0000000..c419694 --- /dev/null +++ b/src/editorPlugins/decorators/index.ts @@ -0,0 +1,4 @@ +export * from "./alignerDecorators"; +export * from "./highlightDecorator"; +export * from "./formattingDecorators"; +export * from "./hiddenSpoiler"; \ No newline at end of file diff --git a/src/editorPlugins/extendedFormattingPlugin.ts b/src/editorPlugins/extendedFormattingPlugin.ts new file mode 100644 index 0000000..31c96ed --- /dev/null +++ b/src/editorPlugins/extendedFormattingPlugin.ts @@ -0,0 +1,85 @@ +import { Decoration, type EditorView, type PluginValue, type DecorationSet, type ViewUpdate, ViewPlugin } from "@codemirror/view"; +import { type Range } from "@codemirror/state"; +import { editorLivePreviewField } from "obsidian"; +import { extendedFormattingField } from "./stateFields"; +import { checkSelectionOverlap } from "../utils/codemirror"; +import { HiddenWidget } from "./widgets"; +import { type DelimPos } from "../types"; +import { formattingDecorators, hiddenSpoiler } from "./decorators"; + +class ExtendedFormatting implements PluginValue { + + decorations: DecorationSet; + isLivePreview: boolean; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + this.isLivePreview = view.state.field(editorLivePreviewField); + } + + update(update: ViewUpdate) { + + if (update.state.field(editorLivePreviewField) == this.isLivePreview) { + this.decorations = this.buildDecorations(update.view); + } + + this.isLivePreview = update.state.field(editorLivePreviewField); + + if (update.docChanged || update.selectionSet) { + this.decorations = this.buildDecorations(update.view); + } + } + + buildDecorations(view: EditorView) { + + let newDecorations: Range[] = []; + + view.state.field(extendedFormattingField).delimField.forEach((posCollection, type) => { + + if (posCollection.length === 0) {return}; + + let decorator = formattingDecorators.get(type) as Decoration; + let delimDecorator = decorator?.spec.delimDeco as Decoration; + + + posCollection.forEach(pos => { + + let hasClosingDelim = pos[2] != pos[3]; + + if (type != "spoiler") { + newDecorations.push(decorator.range(pos[1], pos[2])); + } + + newDecorations.push(delimDecorator.range(pos[0], pos[1])); + if (hasClosingDelim) { + newDecorations.push(delimDecorator.range(pos[2], pos[3])); + } + + if ((!view.hasFocus || !checkSelectionOverlap(view.state.selection, pos[0], pos[3])) && this.isLivePreview) { + type == "spoiler" && newDecorations.push(hiddenSpoiler.range(pos[1], pos[2])); + this.hideDelim(newDecorations, delimDecorator, pos, hasClosingDelim); + } else if (type == "spoiler") { + newDecorations.push(decorator.range(pos[1], pos[2])); + } + }); + }); + + return Decoration.set(newDecorations, true); + } + + hideDelim(decorations: Range[], decorator: Decoration, pos: DelimPos, hasClosingDelim: boolean) { + + let hiddenDelim = Decoration.replace({ + widget: new HiddenWidget(decorator) + }); + + decorations.push(hiddenDelim.range(pos[0], pos[1])); + if (hasClosingDelim) { + decorations.push(hiddenDelim.range(pos[2], pos[3])); + } + } +} + +export const extendedFormattingPlugin = ViewPlugin.fromClass(ExtendedFormatting, { + decorations: value => value.decorations +}); \ No newline at end of file diff --git a/src/editorPlugins/index.ts b/src/editorPlugins/index.ts new file mode 100644 index 0000000..1ef9fac --- /dev/null +++ b/src/editorPlugins/index.ts @@ -0,0 +1,3 @@ +export * from "./extendedFormattingPlugin"; +export * from "./alignerPlugin"; +export * from "./customHighlightPlugin"; \ No newline at end of file diff --git a/src/editorPlugins/stateFields/customHighlightField.ts b/src/editorPlugins/stateFields/customHighlightField.ts new file mode 100644 index 0000000..f9d3a48 --- /dev/null +++ b/src/editorPlugins/stateFields/customHighlightField.ts @@ -0,0 +1,110 @@ +import { type Text, StateField, type StateEffect } from "@codemirror/state"; +import { ParseContext, syntaxTree } from "@codemirror/language"; +import { type SyntaxNode, type Tree } from "@lezer/common"; +import { type HighlightProp } from "../../types"; + +function getHighlight(doc: Text, tree: Tree, previous?: {from: number, hlCollection: HighlightProp[]}) { + + let text = doc.toString(); + let hlChecker = /highlight/; + let hlDelim = /(?:(? { + + if (from >= hlProp[1]) { + + if (from < hlProp[3] || from == hlProp[3]) { + lastIndex = hlProp[0]; + collection.splice(index); + } else { + collection.splice(index + 1); + } + return true; + + } else if (index == 0) { + collection.splice(0); + return true; + } + }); + + hlDelim.lastIndex = lastIndex; + } + + for ( + let hlMatch = hlDelim.exec(text), + node: SyntaxNode; + hlMatch && (node = tree.resolveInner(hlMatch.index, 1)); + hlMatch = hlDelim.exec(text) + ) { + + let openingFrom = node.from; + + if (openingFrom > tree.length) { break } + + if (!hlChecker.test(node.name)) { continue } + + let to: number; + + while (true) { + to = node.to; + node = tree.resolveInner(node.to, 1); + if (!hlChecker.test(node.name)) { break } + } + + let lastNode = tree.resolveInner(to, -1); + let innerHlEnd = /formatting-highlight/.test(lastNode.name) ? lastNode.from : to; + + // opening delim start : openingFrom + // end : hlDelim.lastIndex + // closing delim start : innerHlEnd + // end : to + // highlight color : hlMatch[1] + hlCollection.push([openingFrom, hlDelim.lastIndex, innerHlEnd, to, hlMatch[1]]); + hlDelim.lastIndex = to; + } + + return hlCollection; +} + +export const customHighlightField: StateField<{hlCollection: ReturnType, treeLength: number}> = StateField.define({ + + create(state) { + + let tree = syntaxTree(state); + + return { + hlCollection: getHighlight(state.doc, tree), + treeLength: tree.length + }; + }, + + update(value, transaction) { + + let tree = syntaxTree(transaction.state); + + transaction.effects.forEach((value: StateEffect<{context: ParseContext, tree: Tree}>) => { + tree = (value.value?.tree ?? tree); + }); + + if (transaction.docChanged || tree.length != value.treeLength) { + + let from = Math.min(value.treeLength, tree.length); + value.treeLength = tree.length; + + transaction.changes.iterChangedRanges((fromA) => { + from = Math.min(from, fromA); + }, false); + + getHighlight(transaction.state.doc, tree, {from, hlCollection: value.hlCollection}) + } + + return value; + } +}) \ No newline at end of file diff --git a/src/editorPlugins/stateFields/extendedFormattingField.ts b/src/editorPlugins/stateFields/extendedFormattingField.ts new file mode 100644 index 0000000..40b0161 --- /dev/null +++ b/src/editorPlugins/stateFields/extendedFormattingField.ts @@ -0,0 +1,166 @@ +import { StateField, type Text, type StateEffect } from '@codemirror/state'; +import { syntaxTree, type ParseContext } from '@codemirror/language'; +import { type Tree } from "@lezer/common" +import { type DelimPos } from "../../types"; +import { isColumnSeparator, isRestrictedPos, isTable } from '../../utils/codemirror'; +import { delimRegExps } from '../../regExps'; +import { DelimType } from '../../enums'; + +/** + * Used to get the position of each delimiter in the editor. + * + */ +function getDelimiterFromEditor(doc: Text, tree: Tree, type: DelimType, openingDelim: RegExp, closingDelim: RegExp, previous?: {from: number, delimLength: number, posCollection: DelimPos[]}) { + + let text = doc.toString(), + doubleNewLineChar = /\n\n+/g, + verticalBarChar = /(? { + + if (from >= delimPos[1]) { + + if (from < delimPos[3] || (from == delimPos[3] && delimPos[2] == delimPos[3])) { + lastIndex = delimPos[0]; + collection.splice(index); + } else if (from - delimPos[3] < delimLength) { + lastIndex = delimPos[3]; + collection.splice(index + 1); + } else { + // type == "spoiler" && (lastIndex = Math.max(closingTo, lastIndex)); + collection.splice(index + 1); + } + return true; + + } else if (index == 0) { + collection.splice(0); + return true; + } + }); + + openingDelim.lastIndex = doubleNewLineChar.lastIndex = lastIndex; + } + + mainLoop: for ( + let openingPos = openingDelim.exec(text)?.indices![0], + endLineOffset = doubleNewLineChar.exec(text)?.index ?? text.length; + openingPos; + openingPos = openingDelim.exec(text)?.indices![0] + ) { + + if (openingPos[1] > tree.length) { break } + + if (isRestrictedPos(tree, openingPos[0])) { continue } + + let delimPos: DelimPos = [0, 0, 0, 0]; + + closingDelim.lastIndex = openingPos[1]; + let endCellOffset = 0, + currentLine = doc.lineAt(openingPos[0]), + isTableRow = isTable(tree, openingPos[0]); + + if (isTableRow) { + + if (type == "spoiler") { + openingDelim.lastIndex = currentLine.to; + continue; + } + + for (verticalBarChar.lastIndex = openingPos[0] - currentLine.from;;) { + let verticalBarPos = verticalBarChar.exec(currentLine.text)?.index; + + if (!verticalBarPos) { + endCellOffset = currentLine.to; + break; + + } else if (isColumnSeparator(tree, verticalBarPos + currentLine.from)) { + endCellOffset = verticalBarPos + currentLine.from; + break; + } + } + } + + if (openingDelim.lastIndex > endLineOffset) { + + doubleNewLineChar.lastIndex = openingDelim.lastIndex; + endLineOffset = doubleNewLineChar.exec(text)?.index || text.length; + } + + [delimPos[0], delimPos[1]] = openingPos; + + while (true) { + + let closingPos = closingDelim.exec(text)?.indices![0]; + + if (!closingPos || closingDelim.lastIndex > endLineOffset || (isTableRow && closingDelim.lastIndex > endCellOffset)) { + delimPos[2] = delimPos[3] = openingDelim.lastIndex = isTableRow ? endCellOffset : endLineOffset; + closingDelim.lastIndex > endLineOffset && (endLineOffset = doubleNewLineChar.exec(text)?.index || text.length); + // if (type == "spoiler") { continue mainLoop } + + } else { + if (isRestrictedPos(tree, closingPos[0])) { continue } + openingDelim.lastIndex = closingPos[1]; + [delimPos[2], delimPos[3]] = closingPos; + } + + break; + } + + delimPosCollection.push(delimPos); + } + + closingDelim.lastIndex &&= 0; + return delimPosCollection; +} + +/** + * @returns + */ +export const extendedFormattingField: StateField<{delimField: Map>, treeLength: number}> = StateField.define({ + + create(state) { + + let delimField = new Map>(); + let tree = syntaxTree(state); + + delimRegExps.forEach((value, type) => { + delimField.set(type, getDelimiterFromEditor(state.doc, tree, type, value.openingDelim, value.closingDelim)); + }); + + return {delimField, treeLength: tree.length}; + + }, + + update(value, transaction) { + + let tree = syntaxTree(transaction.state); + + transaction.effects.forEach((value: StateEffect<{context: ParseContext, tree: Tree}>) => { + tree = (value.value?.tree ?? tree); + }); + + if (transaction.docChanged || tree.length != value.treeLength) { + + let from = Math.min(value.treeLength, tree.length); + value.treeLength = tree.length; + + transaction.changes.iterChangedRanges((fromA) => { + from = Math.min(from, fromA); + }, false); + + value.delimField.forEach((posCollection, type) => { + let {openingDelim, closingDelim, length} = delimRegExps.get(type)!; + getDelimiterFromEditor(transaction.state.doc, tree, type, openingDelim, closingDelim, {from, delimLength: length, posCollection}); + }); + } + + return value; + } +}); \ No newline at end of file diff --git a/src/editorPlugins/stateFields/index.ts b/src/editorPlugins/stateFields/index.ts new file mode 100644 index 0000000..47fc6de --- /dev/null +++ b/src/editorPlugins/stateFields/index.ts @@ -0,0 +1,2 @@ +export * from "./extendedFormattingField"; +export * from "./customHighlightField"; \ No newline at end of file diff --git a/src/editorPlugins/widgets/ColorButton.ts b/src/editorPlugins/widgets/ColorButton.ts new file mode 100644 index 0000000..02718a3 --- /dev/null +++ b/src/editorPlugins/widgets/ColorButton.ts @@ -0,0 +1,110 @@ +import { WidgetType, EditorView } from "@codemirror/view" +import { Menu } from "obsidian"; + +/** + * These code snippets are taken from + * https://github.com/Superschnizel/obisdian-fast-text-color/blob/master/src/widgets/ColorWidget.ts + * with some modifications. +*/ +export class ColorButton extends WidgetType { + + color: string; + markerFrom: number; + markerTo: number; + menu: Menu | null; + outerFrom: number; + outerTo: number; + innerFrom: number; + innerTo: number; + + private readonly colors = ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink", "accent", "default"]; + + constructor(color: string = "default", markerFrom: number, markerTo: number, outerFrom: number, outerTo: number, innerFrom: number, innerTo: number) { + super(); + this.color = color; + this.markerFrom = markerFrom; + this.markerTo = markerTo; + this.outerFrom = outerFrom; + this.outerTo = outerTo; + this.innerFrom = innerFrom; + this.innerTo = innerTo; + } + + eq(other: ColorButton) { + return other.color == this.color; + } + + toDOM(view: EditorView): HTMLElement { + let btn = document.createElement("span"); + + btn.setAttribute("aria-hidden", "true"); + btn.className = "cmx-color-btn"; + + btn.onclick = evt => { + view.dispatch({ + selection: { + anchor: this.markerFrom, + head: this.markerTo + } + }); + } + + btn.onmouseover = evt => { + if (this.menu != null) { + return; + } + + this.menu = new Menu(); + + // @ts-ignore + (this.menu.dom as HTMLElement).addClass("es-highlight-colors-modal"); + + this.colors.forEach((color) => { + this.menu!.addItem((item) => { + item + .setTitle(color) + .onClick(evt => { + view.dispatch({ + changes: { + from: this.markerFrom, + to: this.markerTo, + insert: color != "default" ? `{${color}}` : "" + } + }) + }) + .setIcon("palette"); + // @ts-ignore + (item.dom as HTMLElement).addClass(`es-item-${color}`); + }) + }); + + this.menu.addItem(item => { + item + .setTitle("Remove") + .setIcon("ban") + .onClick((evt) => { + view.dispatch({ + changes: [{ + from: this.outerFrom, + to: this.markerTo, + insert: '' + }, { + from: this.innerTo, + to: this.outerTo, + insert: '' + }] + }); + }); + }); + + const rect = btn.getBoundingClientRect(); + this.menu.showAtPosition({ x: rect.left, y: rect.bottom }) + } + + return btn; + } + + ignoreEvent() { + return false; + } +} \ No newline at end of file diff --git a/src/editorPlugins/widgets/HiddenWidget.ts b/src/editorPlugins/widgets/HiddenWidget.ts new file mode 100644 index 0000000..ca20acc --- /dev/null +++ b/src/editorPlugins/widgets/HiddenWidget.ts @@ -0,0 +1,19 @@ +import { WidgetType, Decoration, EditorView } from "@codemirror/view" + +export class HiddenWidget extends WidgetType { + + mark: Decoration; + + constructor(formattingMark: Decoration) { + super(); + this.mark = formattingMark; + } + + eq(other: HiddenWidget) { + return other.mark == this.mark; + } + + toDOM(view: EditorView): HTMLElement { + return document.createElement("span"); + } +} \ No newline at end of file diff --git a/src/editorPlugins/widgets/index.ts b/src/editorPlugins/widgets/index.ts new file mode 100644 index 0000000..5e44034 --- /dev/null +++ b/src/editorPlugins/widgets/index.ts @@ -0,0 +1,2 @@ +export * from "./ColorButton"; +export * from "./HiddenWidget"; \ No newline at end of file diff --git a/src/enums/DelimType.ts b/src/enums/DelimType.ts new file mode 100644 index 0000000..9ebb579 --- /dev/null +++ b/src/enums/DelimType.ts @@ -0,0 +1,6 @@ +export enum DelimType { + U = "u", + Sup = "sup", + Sub = "sub", + Spoiler = "spoiler" +} \ No newline at end of file diff --git a/src/enums/index.ts b/src/enums/index.ts new file mode 100644 index 0000000..ab1d2ac --- /dev/null +++ b/src/enums/index.ts @@ -0,0 +1 @@ +export * from "./DelimType"; \ No newline at end of file diff --git a/src/interfaces/BlockquoteChildSection.ts b/src/interfaces/BlockquoteChildSection.ts new file mode 100644 index 0000000..c721e79 --- /dev/null +++ b/src/interfaces/BlockquoteChildSection.ts @@ -0,0 +1,4 @@ +export interface BlockquoteChildSection { + text: string; + level: number; +} \ No newline at end of file diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts new file mode 100644 index 0000000..59b058f --- /dev/null +++ b/src/interfaces/index.ts @@ -0,0 +1 @@ +export * from "./BlockquoteChildSection"; \ No newline at end of file diff --git a/src/postProcessor/AlignerPostProcessor.ts b/src/postProcessor/AlignerPostProcessor.ts new file mode 100644 index 0000000..c0e3a1c --- /dev/null +++ b/src/postProcessor/AlignerPostProcessor.ts @@ -0,0 +1,49 @@ +import { type MarkdownPostProcessor } from "obsidian"; +import { type ExtendedElement } from "../types"; + +export class AlignerPostProcessor { + + private readonly targetedElements = 'p, h1, h2, h3, h4, h5, h6, td, th, .callout-title-inner'; + + constructor() {} + + private format = (el: ExtendedElement) => { + + let alignMark = /^!((?:left)|(?:right)|(?:center)|(?:justify))!/; + let alignMarkExecArr: RegExpExecArray | null; + + if (el.parentElement?.tagName == "BLOCKQUOTE") { return } + + if (el.parentElement?.hasClass("callout-content")) { + alignMark = /^ *!((?:left)|(?:right)|(?:center)|(?:justify))!/; + alignMarkExecArr = alignMark.exec(el.innerHTML); + + } else if (el.toString() == "[object HTMLHeadingElement]" && el.childNodes[1].nodeType == 3) { + alignMarkExecArr = alignMark.exec(el.childNodes[1].textContent!); + + } else { + alignMarkExecArr = alignMark.exec(el.innerHTML); + } + + if (alignMarkExecArr) { + + el.innerHTML = el.innerHTML.replace(alignMarkExecArr[0], ""); + + if (el.hasClass("callout-title-inner")) { + el.parentElement!.style.justifyContent = alignMarkExecArr[1] != "justify" ? alignMarkExecArr[1] : "space-between"; + } else { + el.style.textAlign = alignMarkExecArr[1]; + } + } + } + + postProcess: MarkdownPostProcessor = (container) => { + if (container.classList.contains("table-cell-wrapper")) { + this.format(container); + } else { + container.querySelectorAll(this.targetedElements).forEach((el) => { + this.format(el); + }); + } + } +} \ No newline at end of file diff --git a/src/postProcessor/CustomHighlightPostProcessor.ts b/src/postProcessor/CustomHighlightPostProcessor.ts new file mode 100644 index 0000000..357d2f1 --- /dev/null +++ b/src/postProcessor/CustomHighlightPostProcessor.ts @@ -0,0 +1,25 @@ +import { type MarkdownPostProcessor } from "obsidian"; +import { type ExtendedElement } from "../types"; + +export class CustomHighlightPostProcessor { + + constructor() {} + + private format = (el: ExtendedElement) => { + + let markElements = el.querySelectorAll("mark"); + let colorMark = /^\{([\w\d\-_]+)\}/; + + markElements.forEach((mark) => { + let colorMarkExecArr = colorMark.exec(mark.innerText); + if (colorMarkExecArr) { + mark.innerHTML = mark.innerHTML.replace(colorMarkExecArr[0], ""); + mark.classList.add(`cmx-highlight-${colorMarkExecArr[1]}`); + } + }); + } + + postProcess: MarkdownPostProcessor = (container, t) => { + this.format(container); + } +} \ No newline at end of file diff --git a/src/postProcessor/ExtendedFormattingPostProcessor.ts b/src/postProcessor/ExtendedFormattingPostProcessor.ts new file mode 100644 index 0000000..a40870e --- /dev/null +++ b/src/postProcessor/ExtendedFormattingPostProcessor.ts @@ -0,0 +1,98 @@ +import { type TableWidget, type DocViewBlock, type BlockWidget } from "@codemirror/view"; +import { type MarkdownView, type Workspace, type MarkdownPostProcessor } from "obsidian"; +import { postProcessorDelimRegExps } from "../regExps"; +import { iterDelimReplacement, splitCells, getBlockquoteSections } from "../utils/postProcess"; +import { getTextAtLine } from "../utils"; + +export class ExtendedFormattingPostProcessor { + + view: MarkdownView | null; + workspace: Workspace; + + private readonly targetedElements = 'p, li, h1, h2, h3, h4, h5, h6, .callout-title-inner'; + private readonly excludedSelector = 'code, a.internal-link'; + + constructor(workspace: Workspace) { + this.view = workspace.activeEditor as typeof this.view; + this.workspace = workspace; + } + + private format(contentEl: HTMLElement, rawText: string, isTableCell?: boolean) { + + postProcessorDelimRegExps.forEach((delimQuery, tagName) => { + iterDelimReplacement(contentEl, rawText, delimQuery, tagName, this.excludedSelector, isTableCell); + }); + } + + postProcess: MarkdownPostProcessor = (container, ctx) => { + + let sectionInfo = ctx.getSectionInfo(container); + + if (sectionInfo) { + + let rawTextData = getTextAtLine(sectionInfo.text, sectionInfo.lineStart, sectionInfo.lineEnd); + let firstChild = container.firstElementChild; + + if (firstChild?.tagName == "TABLE") { + + splitCells(rawTextData).forEach((rawText, i) => { + this.format(container.querySelectorAll("td, th")[i], rawText, true); + }); + + } else if (firstChild?.tagName == "BLOCKQUOTE") { + + getBlockquoteSections(rawTextData, false).forEach((section, i) => { + this.format(container.querySelectorAll(this.targetedElements)[i], section.text); + }); + + } else if (firstChild?.classList.contains("callout")) { + + getBlockquoteSections(rawTextData, true).forEach((section, i) => { + this.format(container.querySelectorAll(this.targetedElements)[i], section.text); + }); + + } else { + + container.querySelectorAll(this.targetedElements).forEach(contentEl => { + this.format(contentEl, rawTextData); + }); + } + + container.querySelectorAll("spoiler").forEach(el => { + el.addEventListener("click", event => { + let spoiler = event.currentTarget as HTMLElement; + spoiler.hasClass("cmx-revealed") ? spoiler.removeClass("cmx-revealed") : spoiler.addClass("cmx-revealed"); + }) + }) + + } else { + + let cmEditor = this.view?.editor?.cm ?? + this.workspace._["quick-preview"] + .find((evtRef) => evtRef.ctx?.hasOwnProperty("editMode") && evtRef.ctx?.path == ctx.sourcePath) + ?.ctx?.editMode.cm!; + + if (container.classList.contains("table-cell-wrapper")) { + + let tableBlock: DocViewBlock | undefined = cmEditor.docView.children.find(el => el.widget?.containerEl == ctx.containerEl); + let tableCells = tableBlock?.widget?.cellChildMap.keys()!; + + for (let cell of tableCells) { + if (cell.contentEl == ctx.el) { + this.format(container, cell.text, true); + break; + } + } + + } else if (ctx.containerEl.classList.contains("cm-callout")) { + + let calloutBlock: DocViewBlock | undefined = cmEditor.docView.children.find(el => el.widget?.containerEl == ctx.containerEl); + let calloutText = calloutBlock?.widget?.text!; + + getBlockquoteSections(calloutText, true).forEach((section, i) => { + this.format(container.querySelectorAll(this.targetedElements)[i], section.text); + }); + } + } + } +} \ No newline at end of file diff --git a/src/postProcessor/index.ts b/src/postProcessor/index.ts new file mode 100644 index 0000000..3f9c984 --- /dev/null +++ b/src/postProcessor/index.ts @@ -0,0 +1,3 @@ +export * from "./AlignerPostProcessor"; +export * from "./CustomHighlightPostProcessor"; +export * from "./ExtendedFormattingPostProcessor"; \ No newline at end of file diff --git a/src/regExps/delimRegExps.ts b/src/regExps/delimRegExps.ts new file mode 100644 index 0000000..b15fe3b --- /dev/null +++ b/src/regExps/delimRegExps.ts @@ -0,0 +1,32 @@ +import { DelimType } from "../enums"; + +export const delimRegExps = new Map([ + [DelimType.U, { + openingDelim: /(?:(?\n?))/gd, + closingDelim: /(?\n?))\+\+/gd, + raw: { + openingDelim: /((?\n?))/gd, + closingDelim: /(?\n?))\^/gd, + raw: { + openingDelim: /((?\n?))/gd, + closingDelim: /(?\n?))~/gd, + raw: { + openingDelim: /((?\n?))/gd, + closingDelim: /(?\n?))\|\|/gd, + raw: { + openingDelim: /((?= from && range.from <= to) { + return true; + } + return false; + } + return false; +} \ No newline at end of file diff --git a/src/utils/codemirror/index.ts b/src/utils/codemirror/index.ts new file mode 100644 index 0000000..867a72b --- /dev/null +++ b/src/utils/codemirror/index.ts @@ -0,0 +1,7 @@ +export * from "./checkSelectionOverlap"; +export * from "./isCodeblock"; +export * from "./isHighlight"; +export * from "./isRestrictedRange"; +export * from "./isColumnSeparator"; +export * from "./isRestrictedPos"; +export * from "./isTable"; \ No newline at end of file diff --git a/src/utils/codemirror/isCodeblock.ts b/src/utils/codemirror/isCodeblock.ts new file mode 100644 index 0000000..9402bce --- /dev/null +++ b/src/utils/codemirror/isCodeblock.ts @@ -0,0 +1,18 @@ +import { syntaxTree } from "@codemirror/language"; +import { EditorView } from "@codemirror/view"; + +export function isCodeblock(view: EditorView, from: number, to: number): boolean { + let isCodeblock: boolean = false; + syntaxTree(view.state).iterate({ + from, + to, + enter: (node) => { + // console.log(node.name); + if (/^inline-code/.test(node.name) || node.name == 'HyperMD-codeblock_HyperMD-codeblock-bg') { + isCodeblock = true; + return false; // short circuit child iteration + } + } + }); + return isCodeblock; +} \ No newline at end of file diff --git a/src/utils/codemirror/isColumnSeparator.ts b/src/utils/codemirror/isColumnSeparator.ts new file mode 100644 index 0000000..b038461 --- /dev/null +++ b/src/utils/codemirror/isColumnSeparator.ts @@ -0,0 +1,5 @@ +import { type Tree } from "@lezer/common"; + +export function isColumnSeparator(tree: Tree, pos: number): boolean { + return /hmd-table-sep/.test(tree.resolveInner(pos, 1).name); +} \ No newline at end of file diff --git a/src/utils/codemirror/isHighlight.ts b/src/utils/codemirror/isHighlight.ts new file mode 100644 index 0000000..c747a19 --- /dev/null +++ b/src/utils/codemirror/isHighlight.ts @@ -0,0 +1,17 @@ +import { syntaxTree } from "@codemirror/language"; +import { EditorView } from "@codemirror/view"; + +export function isHighlight(view: EditorView, from: number, to: number): boolean { + let isHighlightFromPos: boolean = false; + syntaxTree(view.state).iterate({ + from, + to, + enter: (node) => { + if (/highlight/.test(node.name)) { + isHighlightFromPos = true; + return false; // short circuit child iteration + } + } + }); + return isHighlightFromPos; +} \ No newline at end of file diff --git a/src/utils/codemirror/isRestrictedPos.ts b/src/utils/codemirror/isRestrictedPos.ts new file mode 100644 index 0000000..aaa0801 --- /dev/null +++ b/src/utils/codemirror/isRestrictedPos.ts @@ -0,0 +1,6 @@ +import { type Tree } from "@lezer/common"; + +export function isRestrictedPos(tree: Tree, pos: number): boolean { + return /(?:inline-code)|(?:tag)|(?:HyperMD-codeblock)|(?:footref)|(?:hmd-internal-link)|(?:hmd-footnote)|(?:math)|(?:hmd-codeblock)|(?:formatting-strikethrough)/ + .test(tree.resolveInner(pos, 1).name); +} \ No newline at end of file diff --git a/src/utils/codemirror/isRestrictedRange.ts b/src/utils/codemirror/isRestrictedRange.ts new file mode 100644 index 0000000..096fc28 --- /dev/null +++ b/src/utils/codemirror/isRestrictedRange.ts @@ -0,0 +1,27 @@ +import { syntaxTree } from "@codemirror/language"; +import { type EditorState } from "@codemirror/state"; + +/** + * Ensure whether this range will be parsed or not + * + * @param type - the type of delimiter + * @returns {boolean} if it returns true then the range between {@link from} and {@link to} will not be parsed + */ +export function isRestrictedRange(state: EditorState, from: number, to: number, type: string): boolean { + let isRestrictedRange: boolean = false; + syntaxTree(state).iterate({ + from, + to, + enter: (node) => { + if (/(?:^inline-code)|(?:^tag)|(?:HyperMD-codeblock)|(?:blockid)|(?:footref_hmd-barelink)|(?:hmd-internal-link)|(?:hmd-barelink_hmd-footnote)|(?:^math)|(?:formatting-code-block)/.test(node.name)) { + isRestrictedRange = true; + return false; // short circuit child iteration + } + if (type == "sub" && /formatting-strikethrough/.test(node.name)) { + isRestrictedRange = true; + return false; + } + } + }); + return isRestrictedRange; +} \ No newline at end of file diff --git a/src/utils/codemirror/isTable.ts b/src/utils/codemirror/isTable.ts new file mode 100644 index 0000000..26dfc7f --- /dev/null +++ b/src/utils/codemirror/isTable.ts @@ -0,0 +1,16 @@ +import { type Tree } from "@lezer/common"; + +export function isTable(tree: Tree, pos: number): boolean { + let isTable: boolean = false; + tree.iterate({ + from: pos, + to: pos, + enter: (node) => { + if (/table/.test(node.name)) { + isTable = true; + return false; // short circuit child iteration + } + } + }); + return isTable; +} \ No newline at end of file diff --git a/src/utils/getTextAtLine.ts b/src/utils/getTextAtLine.ts new file mode 100644 index 0000000..c8ccb77 --- /dev/null +++ b/src/utils/getTextAtLine.ts @@ -0,0 +1,6 @@ +/** + * Get text on a specific line, based on newline character (\n). + */ +export function getTextAtLine(text: string, lineStart: number, lineEnd: number) { + return text.split("\n").slice(lineStart, lineEnd + 1).join("\n"); +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..a2daa15 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./getTextAtLine"; +export * from "./replaceStringAtPos"; \ No newline at end of file diff --git a/src/utils/postProcess/excludeElements.ts b/src/utils/postProcess/excludeElements.ts new file mode 100644 index 0000000..3068437 --- /dev/null +++ b/src/utils/postProcess/excludeElements.ts @@ -0,0 +1,27 @@ +/** + * @deprecated + */ +export function excludeElements(contentEl: HTMLElement, selector: string, callback: (...args: P) => T, ...args: Parameters): T { + + let excludedContents: string[] = []; + let excludedEl = contentEl.querySelectorAll(selector); + + if (excludedEl.length) { + + excludedEl.forEach((el) => { + excludedContents.push(el.innerHTML); + el.innerHTML = ""; + }); + + let returnValue = callback(...args); + + excludedEl.forEach((el, index) => { + el.innerHTML = excludedContents[index]; + }); + + return returnValue; + + } else { + return callback(...args); + } +} \ No newline at end of file diff --git a/src/utils/postProcess/getBlockquoteSections.ts b/src/utils/postProcess/getBlockquoteSections.ts new file mode 100644 index 0000000..c977929 --- /dev/null +++ b/src/utils/postProcess/getBlockquoteSections.ts @@ -0,0 +1,106 @@ +import { BlockquoteChildSection } from "../../interfaces"; + +/** + * Splits the blockquote/callout into {@link BlockquoteChildSection sections}. + * This function also removes codeblocks, mathblocks, and empty row instead + * of keeping them in the sections, as they will not be parsed. Rows that are + * not interspersed by empty row will be considered as one section. + * + * @example + * ```` + * > first section + * > merged to the first + * > + * > $$ + * > math block + * > $$ + * > ``` + * > codeblock + * > ``` + * > + * > second section + * ```` + * + * The blockquote above will be divided into 2 sections + * + * @param text - Must be a markdown text representing a blockquote/callout + * @param isCallout - If true then the first line representing callout title will be a standalone section + */ +export function getBlockquoteSections(text: string, isCallout: boolean) { + + let blockquoteDelim = /^ *>/; + let sections: BlockquoteChildSection[] = []; + + text.split("\n").forEach((text) => { // splits blockquotes into lines based on "\n" character + + /** blockquote level */ + let level = 0; + + while (blockquoteDelim.test(text) && ++level) { // the blockquote level is dependent on the number of delimiters + text = text.replace(blockquoteDelim, ""); // strips out the blockquote/callout delimiters (">") + } + + text = text.trimStart(); + sections.push({ + text: text, + level: level + }); + }); + + // splits the blockquote/callout into sections + loop1: for ( + let i = 0, + /** if true then the following row will be merged with the previous row */ + mergePrevious = false; + i < sections.length; + ) { + + let currentLine = sections[i]; + + if (isCallout && i === 0) { + i++; + continue; + } + + if (/^```/.test(currentLine.text)) { // removes codeblock + + let k = 1; + while (sections[i + k]) { + let {level, text} = sections[i + k++]; + if ( + (text === "" && level < currentLine.level) || + (/^``` *$/.test(text) && level == currentLine.level) + ) { break } + } + + sections.splice(i, k), mergePrevious &&= false; + continue loop1; + + } else if (/^\$\$/.test(currentLine.text)) { // removes mathblock + + let k = 1; + while (sections[i + k]) { + let {level, text} = sections[i + k++]; + if ( + (text === "" && level < currentLine.level && k--) || + (/^\$\$ *$/.test(text) && level == currentLine.level) + ) { break } + } + + if (k !== 1) { + sections.splice(i, k), mergePrevious &&= false; + continue loop1; + } + + } else if (currentLine.text === "") { // removes empty row + sections.splice(i, 1), mergePrevious &&= false; + continue loop1; + } + + // rows that are not interspersed by empty row will be considered as one section. + (mergePrevious && sections[i - 1].level >= currentLine.level) && (sections[i - 1].text += `\n${sections.splice(i, 1)[0].text}`, i--); + i++, mergePrevious ||= true; + } + + return sections; +} \ No newline at end of file diff --git a/src/utils/postProcess/getDelimiterFromPreview.ts b/src/utils/postProcess/getDelimiterFromPreview.ts new file mode 100644 index 0000000..9bad97c --- /dev/null +++ b/src/utils/postProcess/getDelimiterFromPreview.ts @@ -0,0 +1,122 @@ +import { type DelimPos } from "../../types"; +import { type Pos } from "../../types"; +import { getEscapedDelims } from "./getEscapedDelims"; +import { type postProcessorDelimRegExps } from "../../regExps"; + +/** + * This function is used to get the range containing delimiter + * position of text in preview mode. It uses a similar + * algorithm to [`getDelimiterFromEditor`](../../editorPlugins/stateFields/extendedFormattingField.ts). + * + * @param text - preprocessed text, not raw markdown + * @param rawText - raw markdown text to retrieve the index of escaped delimiters if any + */ +export function getDelimiterFromPreview(text: string, rawText: string, delimQuery: ReturnType) { + + let newLineChar = /(
\n?)\1+/g; + let htmlTagRegExp = /<[\w\d\-]+(?: *[\w\d\-]+(?:=(?:"[^"]*")|(?:'[^']*'))?)*>/g; + let currentLineEndOffset = newLineChar.exec(text)?.index ?? text.length; + + let currentTagExecArr = htmlTagRegExp.exec(text); + let currentTagRange: Pos = [ + currentTagExecArr?.index ?? text.length, + htmlTagRegExp.lastIndex || text.length + ]; + + let {openingDelim, closingDelim, raw} = delimQuery!; + let escapedIndexes = getEscapedDelims(rawText, raw.openingDelim, raw.closingDelim); + + let delimPosCollection: DelimPos[] = []; + + openingDelim.lastIndex = 0; + closingDelim.lastIndex = 0; + + for ( + let openingMatch = openingDelim.exec(text), lastIndex = openingDelim.lastIndex, i = 0; + openingMatch; + openingMatch = openingDelim.exec(text), lastIndex = openingDelim.lastIndex + ) { + + let delimPos: DelimPos = [0, 0, 0, 0] + + if (lastIndex > currentTagRange[1]) { + htmlTagRegExp.lastIndex = lastIndex; + currentTagRange = [ + htmlTagRegExp.exec(text)?.index || text.length, + htmlTagRegExp.lastIndex || text.length + ]; + } + + if (lastIndex > currentTagRange[0] && lastIndex < currentTagRange[1]) { + continue; + } + + /** + * if the order of the matched delimiter ({@link i}) is the same as the index of + * the escaped delimiter ({@link escapedIndexes}) then the previously matched + * delimiter is considered escaped. + */ + if (escapedIndexes[0] === i) { + openingDelim.lastIndex = openingMatch.index + 1; + escapedIndexes.shift(); + i++; + continue; + } + + if (lastIndex > currentLineEndOffset) { + newLineChar.lastIndex = lastIndex; + currentLineEndOffset = newLineChar.exec(text)?.index || text.length; + } + + closingDelim.lastIndex = lastIndex; + [delimPos[0], delimPos[1]] = openingMatch.indices![0]; + i++; + + for (let closingMatch = closingDelim.exec(text);; closingMatch = closingDelim.exec(text)) { + + lastIndex = closingDelim.lastIndex; + + if (!closingMatch || lastIndex > currentLineEndOffset) { + + openingDelim.lastIndex = delimPos[2] = delimPos[3] = currentLineEndOffset; + currentLineEndOffset = newLineChar.exec(text)?.index || text.length; + + } else if (lastIndex > currentTagRange[0] && lastIndex < currentTagRange[1]) { + + continue; + + /** + * if the order of the matched delimiter ({@link i}) is the same as the index of + * the escaped delimiter ({@link escapedIndexes}) then the previously matched + * delimiter is considered escaped. + */ + } else if (escapedIndexes[0] === i) { + + closingDelim.lastIndex = closingMatch.index + 1; + escapedIndexes.shift(); + i++; + continue; + + } else { + + if (lastIndex > currentTagRange[1]) { + htmlTagRegExp.lastIndex = lastIndex; + currentTagRange = [ + htmlTagRegExp.exec(text)?.index || text.length, + htmlTagRegExp.lastIndex || text.length + ]; + } + + [delimPos[2], delimPos[3]] = closingMatch.indices![0]; + openingDelim.lastIndex = lastIndex; + i++; + } + + break; + } + + delimPosCollection.push(delimPos); + } + + return delimPosCollection; +} \ No newline at end of file diff --git a/src/utils/postProcess/getEscapedDelims.ts b/src/utils/postProcess/getEscapedDelims.ts new file mode 100644 index 0000000..64d9246 --- /dev/null +++ b/src/utils/postProcess/getEscapedDelims.ts @@ -0,0 +1,56 @@ +import { getRestrictedRanges } from "../postProcess"; + +export function getEscapedDelims(text: string, openingDelim: RegExp, closingDelim: RegExp) { + + let restrictedRanges = getRestrictedRanges(text); + let indexes: number[] = []; + + openingDelim.lastIndex = 0; + closingDelim.lastIndex = 0; + + for (let openingMatch = openingDelim.exec(text), i = 0; openingMatch; openingMatch = openingDelim.exec(text)) { + + let [all, escaper, target, slippedBackslash, next] = openingMatch.indices!; + + if (restrictedRanges.some(range => range[0] <= all[0] && range[1] >= all[1])) { + continue; + } + + closingDelim.lastIndex = all[1]; + + if (escaper || slippedBackslash || next) { + indexes.push(i); + openingDelim.lastIndex = target[0] + 1; + i++; + continue; + } + + i++; + + for( + let closingMatch = closingDelim.exec(text); + closingMatch || ((openingDelim.lastIndex = text.length), false); + closingMatch = closingDelim.exec(text) + ) { + + let [all, escaper, nonEscaper, target, slippedBackslash] = [...closingMatch!.indices!]; + + if (restrictedRanges.some(range => range[0] <= all[0] && range[1] >= all[1])) { + continue; + } + + if (escaper || slippedBackslash) { + indexes.push(i); + closingDelim.lastIndex = target[0] + 1; + i++; + continue; + } + + i++; + openingDelim.lastIndex = all[1]; + break; + } + } + + return indexes; +} \ No newline at end of file diff --git a/src/utils/postProcess/getRestrictedRange.ts b/src/utils/postProcess/getRestrictedRange.ts new file mode 100644 index 0000000..c36373e --- /dev/null +++ b/src/utils/postProcess/getRestrictedRange.ts @@ -0,0 +1,13 @@ +import { Pos } from "../../types"; + +export function getRestrictedRanges(text: string) { + + let query = /(\\)|`(?:[^`])+`|\[\[\[?(?!\[)(?:.|\s)+?(?, tagName: DelimType, excludedSelector: string, isTableCell?: boolean) { + + isTableCell && tagName == "spoiler" && (rawText = ""); + + let excludedContents: string[] = []; + let excludedEl = contentEl.querySelectorAll(excludedSelector); + + excludedEl.length && excludedEl.forEach((el) => { + excludedContents.push(el.innerHTML); + el.innerHTML = ""; + }); + + let sanitizedText = contentEl.innerHTML; + let ranges = getDelimiterFromPreview(sanitizedText, rawText, delimQuery); + + if (ranges.length) { + + let delimLength = delimQuery?.length!; + let lengthA = tagName.length + 2 - delimLength; + let lengthB = tagName.length + 3; + let i: number = 0; + + ranges.forEach((value) => { + sanitizedText = replaceStringByPos(sanitizedText, `<${tagName}>`, value[0] + i, value[1] + i); + i += lengthA; + sanitizedText = replaceStringByPos(sanitizedText, ``, value[2] + i, value[3] + i); + i = value[2] == value[3] ? i + lengthB : i + lengthB - delimLength; + }) + }; + + contentEl.innerHTML = sanitizedText; + + excludedContents.forEach((content, index) => { + contentEl.querySelectorAll(excludedSelector)[index].innerHTML = content; + }); +} \ No newline at end of file diff --git a/src/utils/postProcess/splitCells.ts b/src/utils/postProcess/splitCells.ts new file mode 100644 index 0000000..0363c4e --- /dev/null +++ b/src/utils/postProcess/splitCells.ts @@ -0,0 +1,20 @@ +export function splitCells(text: string) { + + let rows = text.split("\n"); + let cells: string[] = []; + + rows.forEach((row, index) => { + + if (index === 1) { return } + + row = row.trim(); + row.startsWith("|") && (row = row.substring(1)); + row.endsWith("|") && (row = row.substring(0, row.length - 1)); + + row.split(/(?:(? { + cells.push(cell); + }); + }); + + return cells; +} \ No newline at end of file diff --git a/src/utils/replaceStringAtPos.ts b/src/utils/replaceStringAtPos.ts new file mode 100644 index 0000000..a11b7d3 --- /dev/null +++ b/src/utils/replaceStringAtPos.ts @@ -0,0 +1,8 @@ +/** + * Replace text by position + */ +export function replaceStringByPos(text: string, replaceValue: string, from: number, to: number) { + let replaceArea = new RegExp(`.{${to - from}}`, "ys"); + replaceArea.lastIndex = from; + return text.replace(replaceArea, replaceValue); +} \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..d68a232 --- /dev/null +++ b/styles.css @@ -0,0 +1,153 @@ +:root { + --color-accent-rgb: 51, 139, 255; +} + +.cm-line { + &.cmx-align-left { + text-align: left; + } + &.cmx-align-right { + text-align: right; + } + &.cmx-align-center { + text-align: center; + } + &.cmx-align-justify { + text-align: justify; + } +} + +.cmx-underline { + text-decoration: underline; +} +.cmx-superscript { + vertical-align: top; + font-size: var(--font-smallest); +} +.cmx-subscript { + vertical-align: bottom; + font-size: var(--font-smallest); +} + +.cm-selectionBackground { + transition-duration: 0.1s; + transition-property: left, width; +} +.cm-s-obsidian .cm-cursor { + transition-property: left, top; + transition-duration: 0.1s; +} + +.cm-s-obsidian span.cm-highlight:has(.cmx-highlight) { + background-color: transparent; + .cmx-highlight { + &:has(.cmx-highlight-first-letter) { + padding-inline-start: 2px; + border-top-left-radius: 2px; + border-bottom-left-radius: 2px; + } + &:has(.cmx-highlight-last-letter) { + padding-inline-end: 2px; + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; + } + } + &.cm-formatting-highlight .cmx-highlight { + padding-inline: 2px; + border-radius: 2px; + } +} + +mark { + padding-inline: 2px; + border-radius: 2px; +} + +.cmx-highlight:has(.cmx-color-btn) { + background-color: transparent; +} + +mark.cmx-highlight-red, .cmx-highlight-red, .cmx-highlight-red .cmx-color-btn, .es-item-red .menu-item-title { + background-color: rgba(var(--color-red-rgb), 0.5); +} +mark.cmx-highlight-orange, .cmx-highlight-orange, .cmx-highlight-orange .cmx-color-btn, .es-item-orange .menu-item-title { + background-color: rgba(var(--color-orange-rgb), 0.5); +} +mark.cmx-highlight-yellow, .cmx-highlight-yellow, .cmx-highlight-yellow .cmx-color-btn, .es-item-yellow .menu-item-title { + background-color: rgba(var(--color-yellow-rgb), 0.5); +} +mark.cmx-highlight-green, .cmx-highlight-green, .cmx-highlight-green .cmx-color-btn, .es-item-green .menu-item-title { + background-color: rgba(var(--color-green-rgb), 0.5); +} +mark.cmx-highlight-cyan, .cmx-highlight-cyan, .cmx-highlight-cyan .cmx-color-btn, .es-item-cyan .menu-item-title { + background-color: rgba(var(--color-cyan-rgb), 0.5); +} +mark.cmx-highlight-blue, .cmx-highlight-blue, .cmx-highlight-blue .cmx-color-btn, .es-item-blue .menu-item-title { + background-color: rgba(var(--color-blue-rgb), 0.5); +} +mark.cmx-highlight-purple, .cmx-highlight-purple, .cmx-highlight-purple .cmx-color-btn, .es-item-purple .menu-item-title { + background-color: rgba(var(--color-purple-rgb), 0.5); +} +mark.cmx-highlight-pink, .cmx-highlight-pink, .cmx-highlight-pink .cmx-color-btn, .es-item-pink .menu-item-title { + background-color: rgba(var(--color-pink-rgb), 0.5); +} +mark.cmx-highlight-accent, .cmx-highlight-accent, .cmx-highlight-accent .cmx-color-btn, .es-item-accent .menu-item-title { + background-color: rgba(var(--color-accent-rgb), 0.5); +} +mark.cmx-highlight-default, .cmx-highlight-default, .cmx-highlight-default .cmx-color-btn, .es-item-default .menu-item-title { + background-color: var(--text-highlight-bg); +} + +.cmx-color-btn { + margin-inline: 2px; + width: 12px; + display: inline-block; + height: 12px; + border: solid 1px; + border-color: #ffffff44; + vertical-align: middle; + border-radius: 2px; +} + +.menu.es-highlight-colors-modal { + & > .menu-item { + padding-top: 3px; + padding-bottom: 3px; + & > .menu-item-title { + /* flex: 0 1 auto; */ + padding-inline: 2px; + padding-block: 1px; + border-radius: 2px; + } + } +} + +.cmx-spoiler:not(.cmx-spoiler-formatting) { + border-radius: 2px; + transition-duration: 0.1s; + transition-property: color; + .theme-dark & { + background-color: var(--color-base-25); + } + .theme-light & { + background-color: var(--color-base-40); + } + &.cmx-spoiler-hidden:not(:is(:hover)) { + color: transparent; + } +} + +spoiler { + border-radius: 2px; + transition-duration: 0.1s; + transition-property: color; + .theme-dark & { + background-color: var(--color-base-25); + } + .theme-light & { + background-color: var(--color-base-40); + } + &:not(:is(.cmx-revealed)) { + color: transparent; + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bef804b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "ES2022", + "moduleResolution": "Bundler", + "target": "ES2023", + "jsx": "react", + "allowImportingTsExtensions": false, + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "types": ["obsidian-typings"] + }, + "exclude": [ + "node_modules", + "**/node_modules/*" + ] +} \ No newline at end of file